CVE-2022-41933 (Java, published 2022-11-23T21:15Z)
Description: value: XWiki Platform is a generic wiki platform offering runtime services for applications built on top of it. When the `reset a forgotten password` feature of XWiki was used, the password was then stored in plain text in database. This only concerns XWiki 13.1RC1 and newer versions. Note that it only concerns the reset password feature available from the "Forgot your password" link in the login view: the features allowing a user to change their password, or for an admin to change a user password are not impacted. This vulnerability is particularly dangerous in combination with other vulnerabilities allowing to perform data leak of personal data from users, such as GHSA-599v-w48h-rjrm. Note that this vulnerability only concerns the users of the main wiki: in case of farms, the users registered on subwiki are not impacted thanks to a bug we discovered when investigating this. The problem has been patched in version 14.6RC1, 14.4.3 and 13.10.8. The patch involves a migration of the impacted users as well as the history of the page, to ensure no password remains in plain text in the database. This migration also involves to inform the users about the possible disclosure of their passwords: by default, two emails are automatically sent to the impacted users. A first email to inform about the possibility that their password have been leaked, and a second email using the reset password feature to ask them to set a new password. It's also possible for administrators to set some properties for the migration: it's possible to decide if the user password should be reset (default) or if the passwords should be kept but only hashed. Note that in the first option, the users won't be able to login anymore until they set a new password if they were impacted. Note that in both options, mails will be sent to users to inform them and encourage them to change their passwords.
Severity: nan (CVSS v3 base score: 6.5)
Weakness: CWE-522 \u2014 Insufficiently Protected Credentials
The product transmits or stores authentication credentials, but it uses an insecure method that is susceptible to unauthorized interception and/or retrieval.
Repository: https://github.com/xwiki/xwiki-platform
Files changed:
xwiki-platform-core/xwiki-platform-mail/xwiki-platform-mail-send/xwiki-platform-mail-send-default/src/main/java/org/xwiki/mail/internal/factory/text/TextMimeMessageFactory.java
xwiki-platform-core/xwiki-platform-mail/xwiki-platform-mail-send/xwiki-platform-mail-send-default/src/main/resources/META-INF/components.txt
xwiki-platform-core/xwiki-platform-mail/xwiki-platform-mail-send/xwiki-platform-mail-send-default/src/test/java/org/xwiki/mail/internal/factory/text/TextMimeMessageFactoryTest.java
xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/store/migration/hibernate/R140600000XWIKI19869DataMigration.java
xwiki-platform-core/xwiki-platform-oldcore/src/main/resources/META-INF/components.txt
xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/java/org/xwiki/security/authentication/internal/DefaultResetPasswordManager.java
xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/java/org/xwiki/security/authentication/internal/R140600000XWIKI19869DataMigrationListener.java
xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/java/org/xwiki/security/authentication/internal/ResetPasswordMailSender.java
xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/resources/ApplicationResources.properties
xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/resources/META-INF/components.txt
xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/test/java/org/xwiki/security/authentication/internal/DefaultResetPasswordManagerTest.java
xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/test/java/org/xwiki/security/authentication/internal/R140600000XWIKI19869DataMigrationListenerTest.java
xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/test/java/org/xwiki/security/authentication/internal/ResetPasswordMailSenderTest.java
xwiki-platform-tools/xwiki-platform-tool-configuration-resources/src/main/resources/xwiki.properties.vm
Commit message: XWIKI-19869: Improve user property storage

  - Provide a new API for sending text based email
  - Provide a migration and a listener for informing users about it


Vulnerable code:
                userXObject.setStringValue("password", newPassword);
import org.xwiki.component.manager.ComponentManager;
    private MailSender mailSender;
    @Named("context")
    private ComponentManager componentManager;
            fromAddress = "no-reply@" + context.getRequest().getServerName();
        parameters.put("from", fromAddress);
        parameters.put("to", email);
        parameters.put("language", context.getLocale());
        MimeMessage message;
            message =
        verify(xObject).setStringValue("password", newPassword);

Fixed code:
/*
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.xwiki.mail.internal.factory.text;

import java.util.Map;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import javax.mail.Address;
import javax.mail.Message;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;

import org.xwiki.component.annotation.Component;
import org.xwiki.mail.ExtendedMimeMessage;
import org.xwiki.mail.MimeBodyPartFactory;
import org.xwiki.mail.MimeMessageFactory;
import org.xwiki.mail.internal.factory.AbstractMimeMessageFactory;
import org.xwiki.properties.ConverterManager;

/**
 * A basic {@link MimeMessageFactory} which is taken a {@link String} as source which represents the content of the
 * message.
 *
 * @version $Id$
 * @since 14.6RC1
 * @since 14.4.3
 * @since 13.10.8
 */
@Component
@Singleton
@Named("text")
public class TextMimeMessageFactory extends AbstractMimeMessageFactory<MimeMessage>
{
    @Inject
    private ConverterManager converterManager;

    @Inject
    private MimeBodyPartFactory<String> mimeBodyPartFactory;

    @Override
    public MimeMessage createMessage(Object source, Map<String, Object> parameters) throws MessagingException
    {
        // This whole code has been inspired by the implementation of
        // org.xwiki.mail.internal.factory.template.AbstractTemplateMimeMessageFactory

        // Note: We don't create a Session here ATM since it's not required. The returned MimeMessage will be
        // given a valid Session when it's deserialized from the mail content store for sending.
        ExtendedMimeMessage message = new ExtendedMimeMessage();

        // Handle optional "from" address.
        Address from = this.converterManager.convert(Address.class, parameters.get("from"));
        if (from != null) {
            message.setFrom(from);
        }

        // Handle optional "to", "cc" and "bcc" addresses.
        setRecipient(message, Message.RecipientType.TO, parameters.get("to"));
        setRecipient(message, Message.RecipientType.CC, parameters.get("cc"));
        setRecipient(message, Message.RecipientType.BCC, parameters.get("bcc"));

        // Handle optional "type" parameter to set the mail type
        // Set the Message type if passed in parameters
        String type = (String) parameters.get("type");
        if (type != null) {
            message.setType(type);
        }

        // Handle the subject. Get it from the parameters.
        String subject = (String) parameters.get("subject");
        message.setSubject(subject);

        // Add a default body part taken from the template.
        Multipart multipart = new MimeMultipart("mixed");
        multipart.addBodyPart(this.mimeBodyPartFactory.create((String) source, parameters));
        message.setContent(multipart);

        return message;
    }

    private void setRecipient(MimeMessage message, Message.RecipientType type, Object value)
        throws MessagingException
    {
        Address[] addresses = this.converterManager.convert(Address[].class, value);
        if (addresses != null) {
            message.setRecipients(type, addresses);
        }
    }
}
org.xwiki.mail.internal.factory.text.TextMimeMessageFactory
/*
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.xwiki.mail.internal.factory.text;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import javax.mail.Address;
import javax.mail.Message;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeBodyPart;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;

import org.junit.jupiter.api.Test;
import org.xwiki.mail.ExtendedMimeMessage;
import org.xwiki.mail.MimeBodyPartFactory;
import org.xwiki.properties.ConverterManager;
import org.xwiki.test.junit5.mockito.ComponentTest;
import org.xwiki.test.junit5.mockito.InjectMockComponents;
import org.xwiki.test.junit5.mockito.MockComponent;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
 * Tests for {@link TextMimeMessageFactory}.
 *
 * @version $Id$
 */
@ComponentTest
class TextMimeMessageFactoryTest
{
    @InjectMockComponents
    private TextMimeMessageFactory textMimeMessageFactory;

    @MockComponent
    private ConverterManager converterManager;

    @MockComponent
    private MimeBodyPartFactory<String> mimeBodyPartFactory;

    @Test
    void createMessage() throws MessagingException, IOException
    {
        String source = "Some mail content";
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("to", "toto@xwiki.com");
        parameters.put("from", "admin@xwiki.com");
        parameters.put("type", "text");
        parameters.put("subject", "important email");

        Address from = new InternetAddress("admin@xwiki.com");
        Address to = new InternetAddress("toto@xwiki.com");

        when(this.converterManager.convert(Address[].class, "toto@xwiki.com")).thenReturn(new Address[] {to});
        when(this.converterManager.convert(Address.class, "admin@xwiki.com")).thenReturn(from);

        MimeBodyPart bodyPart = mock(MimeBodyPart.class);
        when(this.mimeBodyPartFactory.create(source, parameters)).thenReturn(bodyPart);

        ExtendedMimeMessage expectedMessage = new ExtendedMimeMessage();
        expectedMessage.setFrom(from);
        expectedMessage.setRecipient(Message.RecipientType.TO, to);
        expectedMessage.setSubject("important email");
        expectedMessage.setType("text");
        Multipart multipart = new MimeMultipart("mixed");
        multipart.addBodyPart(bodyPart);
        expectedMessage.setContent(multipart);

        MimeMessage message = this.textMimeMessageFactory.createMessage(source, parameters);
        assertEquals(to, message.getRecipients(Message.RecipientType.TO)[0]);
        assertEquals(from, message.getFrom()[0]);
        assertEquals("important email", message.getSubject());
        assertEquals(bodyPart, ((Multipart) message.getContent()).getBodyPart(0));
    }
}
/*
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package com.xpn.xwiki.store.migration.hibernate;

import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
import javax.inject.Singleton;

import org.apache.commons.lang3.exception.ExceptionUtils;
import org.slf4j.Logger;
import org.xwiki.component.annotation.Component;
import org.xwiki.configuration.ConfigurationSource;
import org.xwiki.environment.Environment;
import org.xwiki.model.reference.DocumentReference;
import org.xwiki.model.reference.DocumentReferenceResolver;
import org.xwiki.model.reference.EntityReferenceSerializer;
import org.xwiki.query.Query;
import org.xwiki.query.QueryException;
import org.xwiki.query.QueryFilter;
import org.xwiki.query.QueryManager;
import org.xwiki.user.UserReferenceSerializer;

import com.xpn.xwiki.XWikiContext;
import com.xpn.xwiki.XWikiException;
import com.xpn.xwiki.doc.DocumentRevisionProvider;
import com.xpn.xwiki.doc.XWikiDocument;
import com.xpn.xwiki.doc.XWikiDocumentArchive;
import com.xpn.xwiki.doc.rcs.XWikiRCSNodeInfo;
import com.xpn.xwiki.internal.mandatory.XWikiUsersDocumentInitializer;
import com.xpn.xwiki.objects.BaseObject;
import com.xpn.xwiki.store.migration.DataMigrationException;
import com.xpn.xwiki.store.migration.XWikiDBVersion;

/**
 * Migration responsible of ensuring the password fields are properly hashed.
 *
 * @version $Id$
 * @since 14.6RC1
 * @since 14.4.3
 * @since 13.10.8
 */
@Component
@Singleton
@Named(R140600000XWIKI19869DataMigration.HINT)
public class R140600000XWIKI19869DataMigration extends AbstractHibernateDataMigration
{
    /**
     * The hint for this component.
     */
    public static final String HINT = "140600000XWIKI19869";

    private static final String FILENAME = HINT + "DataMigration.txt";

    private static final String XWQL_QUERY = "select distinct doc.fullName from Document doc, "
        + "doc.object(XWiki.XWikiUsers) objUser where objUser.password not like 'hash:%' and objUser.password <> '' "
        + "order by doc.fullName";

    private static final String PASSWORD_FIELD = "password";

    private static final int BATCH_SIZE = 100;

    @Inject
    private QueryManager queryManager;

    @Inject
    private DocumentReferenceResolver<String> documentReferenceResolver;

    @Inject
    private DocumentRevisionProvider documentRevisionProvider;

    @Inject
    private Provider<UserReferenceSerializer<String>> userReferenceSerializerProvider;

    @Inject
    @Named("count")
    private Provider<QueryFilter> countFilterProvider;

    @Inject
    private EntityReferenceSerializer<String> entityReferenceSerializer;

    @Inject
    private Environment environment;

    @Inject
    @Named("xwikiproperties")
    private Provider<ConfigurationSource> propertiesConfigurationProvider;

    @Inject
    private Logger logger;

    @Override
    public String getDescription()
    {
        return "Migrate wrongly stored passwords information.";
    }

    @Override
    public XWikiDBVersion getVersion()
    {
        return new XWikiDBVersion(140600000);
    }

    @Override
    public boolean shouldExecute(XWikiDBVersion startupVersion)
    {
        // The bug we discovered only impact the main wiki users thanks to another bug (XWIKI-19591),
        // so we can safely ignore subwikis
        return getXWikiContext().isMainWiki();
    }

    @Override
    protected void hibernateMigrate() throws DataMigrationException, XWikiException
    {
        // 1. Find users with a plain text password and then for each user:
        // 2. Set an empty password OR recompute a hash of the password and store it (this choice is made through
        //    the xwiki.properties security.migration.R140600000XWIKI19869.resetPassword)
        // 3. If the reset password feature is enabled force a reset, else log a warning in the console
        // 4. Rewrite the history of the user page to replace plain text passwords info with new password hash from it
        //    (we don't wipe out to avoid problem with rollback)
        boolean resetPassword = this.propertiesConfigurationProvider.get()
            .getProperty("security.migration.R140600000XWIKI19869.resetPassword", true);
        boolean requireMigrationFile = this.propertiesConfigurationProvider.get()
            .getProperty("security.migration.R140600000XWIKI19869.requireMigrationFile", true);
        long numberOfUsersToMigrate = this.getNumberOfUsersToMigrate();
        this.logger.info("The migration will need to process [{}] user documents", numberOfUsersToMigrate);
        File migrationFile = new File(this.environment.getPermanentDirectory(), FILENAME);
        Optional<BufferedWriter> bufferedWriterOptional = Optional.empty();
        try {
            bufferedWriterOptional = Optional.of(Files.newBufferedWriter(migrationFile.toPath(),
                StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.APPEND));
        } catch (IOException e) {
            if (requireMigrationFile) {
                throw new DataMigrationException("Error while trying to create the migration file for sending users "
                    + "instructions to reset their password. If you cannot resolve the problem, you can skip this check"
                    + " by setting the property 'security.migration.R140600000XWIKI19869.requireMigrationFile' "
                    + "in xwiki.properties.", e);
            } else {
                logger.warn("Error while trying to create the migration file to force user to reset their password. "
                        + "The migration will then output in the logs the list of users so that the admin can reach "
                        + "them directly. The cause of the error for creating the file was [{}]",
                    ExceptionUtils.getRootCauseMessage(e));
            }
        }
        try {
            List<DocumentReference> users;
            do {
                users = this.getUsers();

                if (!users.isEmpty()) {
                    this.logger.info("Start processing [{}] users over [{}]", users.size(), numberOfUsersToMigrate);

                    for (DocumentReference userReference : users) {
                        this.handleUser(userReference, resetPassword, bufferedWriterOptional);
                    }
                    bufferedWriterOptional.ifPresent(bufferedWriter -> {
                        try {
                            bufferedWriter.flush();
                        } catch (IOException e) {
                            logger.warn("Error while flushing the buffer to write data migration file: [{}]",
                                ExceptionUtils.getRootCauseMessage(e));
                        }
                    });
                }
            } while (!users.isEmpty());
        } finally {
            bufferedWriterOptional.ifPresent(bufferedWriter -> {
                try {
                    bufferedWriter.flush();
                    bufferedWriter.close();
                } catch (IOException e) {
                    logger.warn("Error while flushing and closing the buffer to write data migration file: [{}]",
                        ExceptionUtils.getRootCauseMessage(e));
                }
            });
        }
    }

    private long getNumberOfUsersToMigrate()
    {
        long result = -1;
        try {
            Query query = this.queryManager.createQuery(XWQL_QUERY, Query.XWQL)
                .addFilter(this.countFilterProvider.get());
            List<Long> countValue = query.execute();
            result = countValue.get(0);
        } catch (QueryException e) {
            // We don't throw an exception because it might be only a problem with the count
            // and this is only used for log purpose.
            // In case of real issue on the query, it will throw an exception when actually getting the users
            this.logger.warn("Error while trying to count the number of users", e);
        }
        return result;
    }

    private List<DocumentReference> getUsers() throws DataMigrationException
    {
        try {
            // Note: we don't need to set an offset, since we call back the query after having processed the users
            // so we shouldn't retrieve back the users already processed.
            Query query = this.queryManager
                .createQuery(XWQL_QUERY, Query.XWQL)
                .setLimit(BATCH_SIZE);
            List<String> usersList = query.execute();

            return usersList.stream().map(this.documentReferenceResolver::resolve).collect(Collectors.toList());
        } catch (QueryException e) {
            throw new DataMigrationException("Error while querying the list of users", e);
        }
    }

    private void handleUser(DocumentReference userDocReference, boolean resetPassword,
        Optional<BufferedWriter> bufferedWriterOptional) throws XWikiException
    {
        XWikiContext context = getXWikiContext();
        XWikiDocument userDoc = context.getWiki().getDocument(userDocReference, context);

        if (fixPasswordHash(userDoc, true, resetPassword)) {
            String serializedUserRef = this.entityReferenceSerializer.serialize(userDocReference);
            this.handleHistory(userDoc);
            context.getWiki().saveDocument(userDoc, context);
            if (bufferedWriterOptional.isPresent()) {
                try {
                    bufferedWriterOptional.get()
                        .write(serializedUserRef + "\n");
                } catch (IOException e) {
                    logger.warn("Error when writing in migration file (root cause was [{}]. Please reach "
                            + "individually [{}] for resetting their password.",
                        ExceptionUtils.getRootCauseMessage(e), serializedUserRef);
                }
            } else {
                logger.warn("Please reach individually [{}] for resetting their password.", serializedUserRef);
            }
        }
    }

    private void handleHistory(XWikiDocument userDoc) throws XWikiException
    {
        XWikiContext context = getXWikiContext();
        XWikiDocumentArchive documentArchive = userDoc.getDocumentArchive(context);
        Collection<XWikiRCSNodeInfo> archiveNodes = documentArchive.getNodes();

        for (XWikiRCSNodeInfo node : new ArrayList<>(archiveNodes)) {
            XWikiDocument revision = this.documentRevisionProvider.getRevision(userDoc, node.getVersion().toString());
            if (fixPasswordHash(revision, false, false)) {
                String author = userReferenceSerializerProvider.get()
                    .serialize(revision.getAuthors().getOriginalMetadataAuthor());
                documentArchive.updateArchive(revision, author, revision.getDate(), revision.getComment(),
                    revision.getRCSVersion(), context);
            }
        }
    }

    /**
     * Set the password value using the appropriate API.
     * The first boolean flag is used to know if the reset is called for the last version of the doc or for one of the
     * history version. The second flag is used to define if the password should be just reset, or if the hash of the
     * defined password should be computed.
     *
     * @param userDoc the document to update
     * @param isMain is {@code true} if the reset is called for the last version of the doc or for one of the
     *               history version.
     * @param resetPassword {@code true} if the password should be just reset, or {@code false} if the hash of the
     *                      defined password should be computed.
     */
    private boolean fixPasswordHash(XWikiDocument userDoc, boolean isMain, boolean resetPassword)
    {
        boolean result = false;
        XWikiContext context = getXWikiContext();
        BaseObject userObj = userDoc.getXObject(XWikiUsersDocumentInitializer.XWIKI_USERS_DOCUMENT_REFERENCE);
        if (userObj != null) {
            String password = userObj.getStringValue(PASSWORD_FIELD);
            if (!password.startsWith("hash:")) {
                if (isMain && resetPassword) {
                    userObj.set(PASSWORD_FIELD, "", context);
                } else {
                    // The set method should automatically compute the hash
                    userObj.set(PASSWORD_FIELD, password, context);
                }
                result = true;
            } else if (isMain) {
                this.logger.warn("User document was wrongly retrieved [{}] it won't be modified.",
                    userDoc.getDocumentReference());
            }
        } else if (isMain) {
            this.logger.warn("Null user object for document [{}] this should never happen.",
                userDoc.getDocumentReference());
        }
        return result;
    }
}
com.xpn.xwiki.store.migration.hibernate.R140600000XWIKI19869DataMigration

                // /!\ We cannot use BaseCollection#setStringValue as it's storing value in plain text.
                userXObject.set("password", newPassword, context);
/*
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.xwiki.security.authentication.internal;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
import javax.inject.Singleton;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.slf4j.Logger;
import org.xwiki.bridge.event.ApplicationReadyEvent;
import org.xwiki.component.annotation.Component;
import org.xwiki.environment.Environment;
import org.xwiki.localization.ContextualLocalizationManager;
import org.xwiki.observation.AbstractEventListener;
import org.xwiki.observation.event.Event;
import org.xwiki.security.authentication.ResetPasswordException;
import org.xwiki.security.authentication.ResetPasswordManager;
import org.xwiki.security.authentication.ResetPasswordRequestResponse;
import org.xwiki.user.UserProperties;
import org.xwiki.user.UserPropertiesResolver;
import org.xwiki.user.UserReference;
import org.xwiki.user.UserReferenceResolver;

/**
 * Listener in charge of checking if a data migration file is there and ask users to reset their passwords.
 *
 * @version $Id$
 * @since 14.6RC1
 * @since 14.4.3
 * @since 13.10.8
 */
@Component
@Singleton
@Named(R140600000XWIKI19869DataMigrationListener.NAME)
public class R140600000XWIKI19869DataMigrationListener extends AbstractEventListener
{
    static final String NAME = "R140600000XWIKI19869DataMigrationListener";
    private static final List<Event> EVENT_LIST = Collections.singletonList(new ApplicationReadyEvent());

    private static final String FILENAME = "140600000XWIKI19869DataMigration.txt";
    private static final String MAIL_TEMPLATE = "140600000XWIKI19869-mail.txt";
    private static final String SUBJECT_MARKER = "subject:";

    @Inject
    private Provider<ResetPasswordManager> resetPasswordManagerProvider;

    @Inject
    private Provider<ResetPasswordMailSender> resetPasswordMailSenderProvider;

    @Inject
    private Provider<UserReferenceResolver<String>> userReferenceResolverProvider;

    @Inject
    private Provider<ContextualLocalizationManager> contextualLocalizationManagerProvider;

    @Inject
    private Provider<UserPropertiesResolver> userPropertiesResolver;

    @Inject
    private Environment environment;

    @Inject
    private Logger logger;

    /**
     * Default constructor.
     */
    public R140600000XWIKI19869DataMigrationListener()
    {
        super(NAME, EVENT_LIST);
    }

    @Override
    public void onEvent(Event event, Object source, Object data)
    {
        File migrationFile = new File(this.environment.getPermanentDirectory(), FILENAME);
        if (Files.exists(migrationFile.toPath())) {
            handleMigrationFile(migrationFile);
        }
    }

    private void handleMigrationFile(File migrationFile)
    {
        try {
            List<String> serializedReferences = Files.readAllLines(migrationFile.toPath());

            // We use a set in case there was same reference multiple times in the file (could happen if the migration
            // was restarted)
            Set<String> exploredReference = new HashSet<>();

            Pair<String, String> mailData = this.getMailData();
            for (String serializedReference : serializedReferences) {
                if (!exploredReference.contains(serializedReference)) {
                    UserReference userReference = this.userReferenceResolverProvider.get().resolve(serializedReference);
                    UserProperties userProperties = this.userPropertiesResolver.get().resolve(userReference);
                    if (userProperties.getEmail() != null) {
                        this.handleResetPassword(userReference, mailData);
                    } else {
                        this.logger.warn("Reset email cannot be sent for user [{}] as no email address is provided.",
                            userReference);
                    }

                    exploredReference.add(serializedReference);
                }
            }

            Files.delete(migrationFile.toPath());
        } catch (IOException e) {
            this.logger.warn("Error while trying to read the data migration file to ask user to reset their password"
                + " the root cause error was [{}]", ExceptionUtils.getRootCauseMessage(e));
        }
    }

    private void handleResetPassword(UserReference userReference, Pair<String, String> mailData)
    {
        ResetPasswordManager resetPasswordManager = this.resetPasswordManagerProvider.get();
        try {
            ResetPasswordRequestResponse resetPasswordRequestResponse =
                resetPasswordManager.requestResetPassword(userReference);
            if (!StringUtils.isEmpty(resetPasswordRequestResponse.getVerificationCode())) {

                this.resetPasswordMailSenderProvider.get().sendAuthenticationSecurityEmail(userReference,
                    mailData.getLeft(), mailData.getRight());
                resetPasswordManager.sendResetPasswordEmailRequest(resetPasswordRequestResponse);
            }
        } catch (ResetPasswordException e) {
            this.logger.warn("Error when trying to force user [{}] to reset their password: [{}]",
                userReference,
                ExceptionUtils.getRootCauseMessage(e));
            this.logger.debug("Full stack trace for the reset password request: ", e);
        }
    }

    private Pair<String, String> getMailData()
    {
        File mailTemplate = new File(this.environment.getPermanentDirectory(), MAIL_TEMPLATE);
        Pair<String, String> result = this.getMailDataFallback();
        if (Files.exists(mailTemplate.toPath())) {
            try {
                List<String> mailLines = Files.readAllLines(mailTemplate.toPath());
                result = getMailData(mailLines, result);
            } catch (IOException e) {
                this.logger.warn("Error while trying to read the security email template, "
                    + "fallback on default mail subject and content. Root cause error: [{}]",
                    ExceptionUtils.getRootCauseMessage(e));
            }
        }
        return result;
    }

    private Pair<String, String> getMailData(List<String> mailLines, Pair<String, String> fallback)
    {
        Pair<String, String> result = fallback;
        if (!mailLines.isEmpty()) {
            String subject;
            if (mailLines.get(0).toLowerCase().startsWith(SUBJECT_MARKER)) {
                subject = mailLines.remove(0).substring(SUBJECT_MARKER.length());
            } else {
                subject = fallback.getLeft();
            }

            String content = String.join("\n", mailLines);
            result = Pair.of(subject, content);
        }
        return result;
    }

    private Pair<String, String> getMailDataFallback()
    {
        ContextualLocalizationManager contextualLocalizationManager = this.contextualLocalizationManagerProvider.get();
        String translationPrefix = "security.authentication.migration1400600000XWIKI19869.email.";
        String subject = contextualLocalizationManager.getTranslationPlain(translationPrefix + "subject");
        String content = contextualLocalizationManager.getTranslationPlain(translationPrefix + "content");
        return Pair.of(subject, content);
    }
}
import org.xwiki.user.UserProperties;
import org.xwiki.user.UserPropertiesResolver;
import org.xwiki.user.UserReference;
    private static final String NO_REPLY = "no-reply@";
    private static final String FROM = "from";
    private static final String TO = "to";

    @Named("text")
    private MimeMessageFactory<MimeMessage> textMimeMessageFactory;
    private MailSender mailSender;
    @Inject
    private Provider<UserPropertiesResolver> userPropertiesResolverProvider;

            fromAddress = NO_REPLY + context.getRequest().getServerName();
        parameters.put(FROM, fromAddress);
        parameters.put(TO, email);
        parameters.put("language", this.contextProvider.get().getLocale());
            MimeMessage message =
            this.sendMessage(message, localizedError);
    }

    /**
     * Allows to send a text email for security purpose.
     *
     * @param userReference the user to whom to send the email.
     * @param subject the localized subject of the email
     * @param mailContent the localized content of the email
     * @throws ResetPasswordException in case of problem for preparing or sending the email
     * @since 14.6RC1
     * @since 14.4.3
     * @since 13.10.8
     */
    public void sendAuthenticationSecurityEmail(UserReference userReference, String subject, String mailContent)
        throws ResetPasswordException
    {
        XWikiContext context = this.contextProvider.get();
        String fromAddress = this.mailSenderConfiguration.getFromAddress();
        if (StringUtils.isEmpty(fromAddress)) {
            fromAddress = NO_REPLY + context.getRequest().getServerName();
        }
        UserProperties userProperties = this.userPropertiesResolverProvider.get().resolve(userReference);
        InternetAddress email = userProperties.getEmail();

        Map<String, Object> parameters = new HashMap<>();
        parameters.put(FROM, fromAddress);
        parameters.put(TO, email);
        parameters.put("subject", subject);

        String localizedError =
            this.localizationManager.getTranslationPlain("security.authentication.security.email.error");

        try {
            MimeMessage message = this.textMimeMessageFactory.createMessage(mailContent, parameters);
            this.sendMessage(message, localizedError);
        } catch (MessagingException e) {
            throw new ResetPasswordException(localizedError, e);
        }
    }

    private void sendMessage(MimeMessage message, String localizedError) throws ResetPasswordException
    {

security.authentication.security.email.error=Error when sending a security email

security.authentication.migration1400600000XWIKI19869.email.subject=Important security issue
security.authentication.migration1400600000XWIKI19869.email.content=Dear user, \n\ndue to a bug your password was stored in plain text in our wiki. We cannot exclude that your plain text password was exposed in an attack. Therefore, you will receive a second email to choose a new password. \nPlease contact the administrator in case of problem or for further questions.
org.xwiki.security.authentication.internal.R140600000XWIKI19869DataMigrationListener
        verify(xObject).set("password", newPassword, context);
/*
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.xwiki.security.authentication.internal;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;

import javax.inject.Inject;
import javax.inject.Provider;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.xwiki.environment.Environment;
import org.xwiki.localization.ContextualLocalizationManager;
import org.xwiki.security.authentication.ResetPasswordManager;
import org.xwiki.security.authentication.ResetPasswordRequestResponse;
import org.xwiki.test.LogLevel;
import org.xwiki.test.junit5.LogCaptureExtension;
import org.xwiki.test.junit5.XWikiTempDir;
import org.xwiki.test.junit5.mockito.ComponentTest;
import org.xwiki.test.junit5.mockito.InjectMockComponents;
import org.xwiki.test.junit5.mockito.MockComponent;
import org.xwiki.user.UserProperties;
import org.xwiki.user.UserPropertiesResolver;
import org.xwiki.user.UserReference;
import org.xwiki.user.UserReferenceResolver;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

/**
 * Tests for {@link R140600000XWIKI19869DataMigrationListener}.
 *
 * @version $Id$
 */
@ComponentTest
class R140600000XWIKI19869DataMigrationListenerTest
{
    @InjectMockComponents
    private R140600000XWIKI19869DataMigrationListener listener;

    @MockComponent
    private ResetPasswordManager resetPasswordManager;

    @MockComponent
    private ResetPasswordMailSender resetPasswordMailSender;

    @MockComponent
    private UserReferenceResolver<String> userReferenceResolver;

    @MockComponent
    private ContextualLocalizationManager contextualLocalizationManager;

    @MockComponent
    private UserPropertiesResolver userPropertiesResolver;

    @MockComponent
    private Environment environment;

    @RegisterExtension
    LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.WARN);

    @Test
    void onEvent(@XWikiTempDir File tmpDir) throws Exception
    {
        when(this.environment.getPermanentDirectory()).thenReturn(tmpDir);
        File migrationFile = new File(tmpDir, "140600000XWIKI19869DataMigration.txt");

        this.listener.onEvent(null, null, null);
        verifyNoInteractions(this.resetPasswordMailSender);
        verifyNoInteractions(this.resetPasswordManager);
        verifyNoInteractions(this.userReferenceResolver);

        Files.writeString(migrationFile.toPath(), "XWiki.Foo\nXWiki.Bar\nXWiki.Buz", StandardOpenOption.CREATE_NEW);

        String mailFallbackSubject = "Subject fallback";
        String mailFallbackContent = "Mail content";
        when(this.contextualLocalizationManager
            .getTranslationPlain("security.authentication.migration1400600000XWIKI19869.email.subject"))
            .thenReturn(mailFallbackSubject);
        when(this.contextualLocalizationManager
            .getTranslationPlain("security.authentication.migration1400600000XWIKI19869.email.content"))
            .thenReturn(mailFallbackContent);

        UserReference fooRef = mock(UserReference.class, "foo");
        UserReference barRef = mock(UserReference.class, "bar");
        UserReference buzRef = mock(UserReference.class, "buz");

        when(this.userReferenceResolver.resolve("XWiki.Foo")).thenReturn(fooRef);
        when(this.userReferenceResolver.resolve("XWiki.Bar")).thenReturn(barRef);
        when(this.userReferenceResolver.resolve("XWiki.Buz")).thenReturn(buzRef);

        UserProperties fooProp = mock(UserProperties.class, "fooProp");
        UserProperties barProp = mock(UserProperties.class, "barProp");
        UserProperties buzProp = mock(UserProperties.class, "buzProp");

        when(this.userPropertiesResolver.resolve(fooRef)).thenReturn(fooProp);
        when(this.userPropertiesResolver.resolve(barRef)).thenReturn(barProp);
        when(this.userPropertiesResolver.resolve(buzRef)).thenReturn(buzProp);

        when(fooProp.getEmail()).thenReturn(new InternetAddress("foo@xwiki.com"));
        when(buzProp.getEmail()).thenReturn(new InternetAddress("buz@xwiki.com"));

        ResetPasswordRequestResponse responseFoo = mock(ResetPasswordRequestResponse.class);
        ResetPasswordRequestResponse responseBuz = mock(ResetPasswordRequestResponse.class);
        when(resetPasswordManager.requestResetPassword(fooRef)).thenReturn(responseFoo);
        when(resetPasswordManager.requestResetPassword(buzRef)).thenReturn(responseBuz);

        when(responseFoo.getVerificationCode()).thenReturn("some code");
        when(responseBuz.getVerificationCode()).thenReturn("some code");

        this.listener.onEvent(null, null, null);
        verify(this.resetPasswordMailSender)
            .sendAuthenticationSecurityEmail(fooRef, mailFallbackSubject, mailFallbackContent);
        verify(this.resetPasswordMailSender)
            .sendAuthenticationSecurityEmail(buzRef, mailFallbackSubject, mailFallbackContent);
        verify(this.resetPasswordMailSender, never()).sendAuthenticationSecurityEmail(eq(barRef), any(), any());

        verify(this.resetPasswordManager).sendResetPasswordEmailRequest(responseFoo);
        verify(this.resetPasswordManager).sendResetPasswordEmailRequest(responseBuz);

        assertEquals(1, this.logCapture.size());
        assertEquals("Reset email cannot be sent for user [bar] as no email address is provided.",
            this.logCapture.getMessage(0));

        // The file should have been deleted just after the first call
        this.listener.onEvent(null, null, null);

        Files.writeString(migrationFile.toPath(), "XWiki.Foo", StandardOpenOption.CREATE);
        File mailTemplate = new File(tmpDir, "140600000XWIKI19869-mail.txt");
        String mailSubject = "test";
        String mailContent = "Some mail content";
        Files.writeString(mailTemplate.toPath(),
            String.format("Subject:%s\n%s", mailSubject, mailContent), StandardOpenOption.CREATE_NEW);

        this.listener.onEvent(null, null, null);

        verify(this.resetPasswordMailSender)
            .sendAuthenticationSecurityEmail(fooRef, mailSubject, mailContent);

        verify(this.resetPasswordManager, times(2)).sendResetPasswordEmailRequest(responseFoo);

        // This one should not have been called twice
        verify(this.resetPasswordManager).sendResetPasswordEmailRequest(responseBuz);
    }
}
import javax.mail.internet.AddressException;
import org.xwiki.user.UserProperties;
import org.xwiki.user.UserPropertiesResolver;
import org.xwiki.user.UserReference;
    @MockComponent
    private UserPropertiesResolver userPropertiesResolver;

    @MockComponent
    @Named("text")
    private MimeMessageFactory<MimeMessage> textMimeMessageFactory;

    }

    @Test
    void sendAuthenticationSecurityEmail() throws MessagingException, ResetPasswordException
    {
        UserReference userReference = mock(UserReference.class);
        String content = "some content";
        String subject = "some subject";

        String fromAdress = "root@xwiki.com";
        when(this.mailSenderConfiguration.getFromAddress()).thenReturn(fromAdress);

        UserProperties userProp = mock(UserProperties.class);
        when(this.userPropertiesResolver.resolve(userReference)).thenReturn(userProp);

        InternetAddress userAddress = new InternetAddress("foo@xwiki.com");
        when(userProp.getEmail()).thenReturn(userAddress);

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("from", fromAdress);
        parameters.put("to", userAddress);
        parameters.put("subject", subject);

        MimeMessage message = mock(MimeMessage.class);
        when(this.textMimeMessageFactory.createMessage(content, parameters)).thenReturn(message);
        Session session = Session.getInstance(new Properties());
        when(this.sessionFactory.create(Collections.emptyMap())).thenReturn(session);
        MailStatusResult mailStatusResult = mock(MailStatusResult.class);
        when(this.mailListener.getMailStatusResult()).thenReturn(mailStatusResult);

        this.resetPasswordMailSender.sendAuthenticationSecurityEmail(userReference, subject, content);

        verify(this.mailSender).sendAsynchronously(Collections.singleton(message), session, this.mailListener);
        verify(mailStatusResult).waitTillProcessed(30L);
        verify(mailStatusResult).getAllErrors();
#-# [Since 14.6RC1]
#-# [Since 14.4.3]
#-# [Since 13.10.8]
#-# This option is only used when performing a migration from a wiki before the versions mentioning above.
#-# This parameter defines if as part of the migration R140600000XWIKI19869 the passwords of impacted user should be
#-# reset or not. It's advised to keep this value as true, now for some usecases advertised administrators might want
#-# their users to keep their passwords nevertheless, then enable the configuration and set it to false before the
#-# migration is executed.
# security.migration.R140600000XWIKI19869.resetPassword = true

#-# [Since 14.6RC1]
#-# [Since 14.4.3]
#-# [Since 13.10.8]
#-# This option is only used when performing a migration from a wiki before the versions mentioning above.
#-# This parameter defines if the migration R140600000XWIKI19869 should be stopped if the migration file used to
#-# send notifications to users cannot be created or written. The default is to consider this file is required to avoid
#-# for the admin to have to notify all users. Now it's possible to skip this, in which case the admin will have to
#-# inform the users manually in case of problems.
# security.migration.R140600000XWIKI19869.requireMigrationFile = true