CVE-2022-41933 (published 2022-11-23T21:15Z)

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.

CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-522
cwe_name: Insufficiently Protected Credentials
description: The product transmits or stores authentication credentials, but it uses an insecure method that is susceptible to unauthorized interception and/or retrieval.
extended_description: 
            
            
            
         
         

Fix information:
repo_url: https://github.com/xwiki/xwiki-platform
commit_details: repo_url: https://github.com/xwiki/xwiki-platform
author: Simon Urli
author_date: 2022-07-01 15:47:31+02:00
msg: 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
repository: repo_url: https://github.com/xwiki/xwiki-platform
repo_name: xwiki/xwiki-platform
description: The XWiki platform
repo_language: Java
owner: xwiki
file_changes: filename: TextMimeMessageFactory.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,109 @@
+/*
+ * 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);
+        }
+    }
+}

programming_language: Java
filename: components.txt
change_type: ModificationType.MODIFY
diff: @@ -33,3 +33,4 @@ org.xwiki.mail.internal.factory.template.DefaultAttachmentConverter
 org.xwiki.mail.internal.factory.files.SerializedFilesMimeMessageFactory
 org.xwiki.mail.internal.factory.group.GroupMimeMessageFactory
 org.xwiki.mail.internal.factory.usersandgroups.UsersAndGroupsMimeMessageFactory
+org.xwiki.mail.internal.factory.text.TextMimeMessageFactory

programming_language: Scala
filename: TextMimeMessageFactoryTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,98 @@
+/*
+ * 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));
+    }
+}
\ No newline at end of file

programming_language: Java
filename: R140600000XWIKI19869DataMigration.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,319 @@
+/*
+ * 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;
+    }
+}

programming_language: Java
filename: components.txt
change_type: ModificationType.MODIFY
diff: @@ -215,6 +215,7 @@ com.xpn.xwiki.store.migration.hibernate.R130200000XWIKI17200DataMigration
 com.xpn.xwiki.store.migration.hibernate.R140000000XWIKI19125DataMigration
 com.xpn.xwiki.store.migration.hibernate.R140200010XWIKI19207DataMigration
 com.xpn.xwiki.store.migration.hibernate.R140200000XWIKI19352DataMigration
+com.xpn.xwiki.store.migration.hibernate.R140600000XWIKI19869DataMigration
 com.xpn.xwiki.store.VoidAttachmentVersioningStore
 com.xpn.xwiki.store.XWikiHibernateStore
 com.xpn.xwiki.store.XWikiHibernateVersioningStore

programming_language: Scala
filename: DefaultResetPasswordManager.java
change_type: ModificationType.MODIFY
diff: @@ -319,7 +319,9 @@ public void resetPassword(UserReference userReference, String newPassword)
                 XWikiDocument userDocument = context.getWiki().getDocument(reference, context);
                 userDocument.removeXObjects(RESET_PASSWORD_REQUEST_CLASS_REFERENCE);
                 BaseObject userXObject = userDocument.getXObject(USER_CLASS_REFERENCE);
-                userXObject.setStringValue("password", newPassword);
+
+                // /!\ We cannot use BaseCollection#setStringValue as it's storing value in plain text.
+                userXObject.set("password", newPassword, context);
 
                 String saveComment = this.localizationManager.getTranslationPlain(
                     "xe.admin.passwordReset.step2.versionComment.passwordReset");

programming_language: Java
filename: R140600000XWIKI19869DataMigrationListener.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,205 @@
+/*
+ * 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);
+    }
+}

programming_language: Java
filename: ResetPasswordMailSender.java
change_type: ModificationType.MODIFY
diff: @@ -35,7 +35,6 @@
 
 import org.apache.commons.lang3.StringUtils;
 import org.xwiki.component.annotation.Component;
-import org.xwiki.component.manager.ComponentManager;
 import org.xwiki.localization.ContextualLocalizationManager;
 import org.xwiki.mail.MailListener;
 import org.xwiki.mail.MailSender;
@@ -48,6 +47,9 @@
 import org.xwiki.model.reference.EntityReference;
 import org.xwiki.model.reference.LocalDocumentReference;
 import org.xwiki.security.authentication.ResetPasswordException;
+import org.xwiki.user.UserProperties;
+import org.xwiki.user.UserPropertiesResolver;
+import org.xwiki.user.UserReference;
 
 import com.xpn.xwiki.XWikiContext;
 
@@ -64,6 +66,10 @@
     private static final LocalDocumentReference RESET_PASSWORD_MAIL_TEMPLATE_REFERENCE =
         new LocalDocumentReference("XWiki", "ResetPasswordMailContent");
 
+    private static final String NO_REPLY = "no-reply@";
+    private static final String FROM = "from";
+    private static final String TO = "to";
+
     @Inject
     private MailSenderConfiguration mailSenderConfiguration;
 
@@ -75,11 +81,11 @@
     private MimeMessageFactory<MimeMessage> mimeMessageFactory;
 
     @Inject
-    private MailSender mailSender;
+    @Named("text")
+    private MimeMessageFactory<MimeMessage> textMimeMessageFactory;
 
     @Inject
-    @Named("context")
-    private ComponentManager componentManager;
+    private MailSender mailSender;
 
     @Inject
     private SessionFactory sessionFactory;
@@ -94,6 +100,9 @@
     @Named("database")
     private Provider<MailListener> mailListenerProvider;
 
+    @Inject
+    private Provider<UserPropertiesResolver> userPropertiesResolverProvider;
+
     /**
      * Send the reset password information by email.
      *
@@ -108,13 +117,13 @@ public void sendResetPasswordEmail(String username, InternetAddress email, URL r
         XWikiContext context = this.contextProvider.get();
         String fromAddress = this.mailSenderConfiguration.getFromAddress();
         if (StringUtils.isEmpty(fromAddress)) {
-            fromAddress = "no-reply@" + context.getRequest().getServerName();
+            fromAddress = NO_REPLY + context.getRequest().getServerName();
         }
 
         Map<String, Object> parameters = new HashMap<>();
-        parameters.put("from", fromAddress);
-        parameters.put("to", email);
-        parameters.put("language", context.getLocale());
+        parameters.put(FROM, fromAddress);
+        parameters.put(TO, email);
+        parameters.put("language", this.contextProvider.get().getLocale());
         parameters.put("type", "Reset Password");
         Map<String, String> velocityVariables = new HashMap<>();
         velocityVariables.put("userName", username);
@@ -124,14 +133,56 @@ public void sendResetPasswordEmail(String username, InternetAddress email, URL r
         String localizedError =
             this.localizationManager.getTranslationPlain("xe.admin.passwordReset.error.emailFailed");
 
-        MimeMessage message;
         try {
-            message =
+            MimeMessage message =
                 this.mimeMessageFactory.createMessage(
                     this.documentReferenceResolver.resolve(RESET_PASSWORD_MAIL_TEMPLATE_REFERENCE), parameters);
+            this.sendMessage(message, localizedError);
         } catch (MessagingException e) {
             throw new ResetPasswordException(localizedError, e);
         }
+    }
+
+    /**
+     * 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
+    {
         MailListener mailListener = this.mailListenerProvider.get();
         this.mailSender.sendAsynchronously(Collections.singleton(message),
             this.sessionFactory.create(Collections.emptyMap()),

programming_language: Java
filename: ApplicationResources.properties
change_type: ModificationType.MODIFY
diff: @@ -20,3 +20,8 @@
 security.authentication.strategy.captcha.errorMessage=Please fill the captcha form to login.
 security.authentication.rest.blockedError=Your account may be blocked after too many attempts to login. Please go to the xwiki login page to get more information.
 security.authentication.strategy.disableAccount.errorMessage=This account has been disabled. Please ask the administrator to enable it back.
+
+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.
\ No newline at end of file

programming_language: Shell
filename: components.txt
change_type: ModificationType.MODIFY
diff: @@ -9,3 +9,4 @@ org.xwiki.security.authentication.internal.resource.AuthenticationResourceRefere
 org.xwiki.security.authentication.internal.resource.AuthenticationResourceReferenceSerializer
 org.xwiki.security.authentication.internal.DefaultResetPasswordManager
 org.xwiki.security.authentication.internal.ResetPasswordMailSender
+org.xwiki.security.authentication.internal.R140600000XWIKI19869DataMigrationListener

programming_language: Scala
filename: DefaultResetPasswordManagerTest.java
change_type: ModificationType.MODIFY
diff: @@ -430,7 +430,7 @@ void resetPassword() throws Exception
         String newPassword = "mypassword";
         this.resetPasswordManager.resetPassword(this.userReference, newPassword);
         verify(this.userDocument).removeXObjects(DefaultResetPasswordManager.RESET_PASSWORD_REQUEST_CLASS_REFERENCE);
-        verify(xObject).setStringValue("password", newPassword);
+        verify(xObject).set("password", newPassword, context);
     }
 
     @Test

programming_language: Java
filename: R140600000XWIKI19869DataMigrationListenerTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,174 @@
+/*
+ * 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);
+    }
+}
\ No newline at end of file

programming_language: Java
filename: ResetPasswordMailSenderTest.java
change_type: ModificationType.MODIFY
diff: @@ -32,6 +32,7 @@
 import javax.inject.Provider;
 import javax.mail.MessagingException;
 import javax.mail.Session;
+import javax.mail.internet.AddressException;
 import javax.mail.internet.InternetAddress;
 import javax.mail.internet.MimeMessage;
 
@@ -56,6 +57,9 @@
 import org.xwiki.test.junit5.mockito.InjectMockComponents;
 import org.xwiki.test.junit5.mockito.MockComponent;
 import org.xwiki.test.mockito.MockitoComponentManager;
+import org.xwiki.user.UserProperties;
+import org.xwiki.user.UserPropertiesResolver;
+import org.xwiki.user.UserReference;
 
 import com.xpn.xwiki.XWikiContext;
 
@@ -107,6 +111,13 @@
     @MockComponent
     private Provider<XWikiContext> contextProvider;
 
+    @MockComponent
+    private UserPropertiesResolver userPropertiesResolver;
+
+    @MockComponent
+    @Named("text")
+    private MimeMessageFactory<MimeMessage> textMimeMessageFactory;
+
     private XWikiContext xWikiContext;
 
     private DocumentReference templateDocumentReference;
@@ -241,6 +252,41 @@ void sendResetPasswordEmailErrorWhenSending() throws Exception
         verify(this.mailSender).sendAsynchronously(Collections.singleton(message), session, this.mailListener);
         verify(mailStatusResult).waitTillProcessed(30L);
         assertEquals("Cannot send this email - Some sending error", resetPasswordException.getMessage());
+    }
+
+    @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();
 
     }
 }

programming_language: Java
filename: xwiki.properties.vm
change_type: ModificationType.MODIFY
diff: @@ -766,6 +766,26 @@ distribution.automaticStartOnWiki=$xwikiPropertiesAutomaticStartOnWiki
 #-# The default is:
 # security.authentication.resetPasswordTokenLifetime = 0
 
+#-# [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
+
 #-------------------------------------------------------------------------------------
 # URL
 #-------------------------------------------------------------------------------------

programming_language: Ruby