diff --git a/docs-core/pom.xml b/docs-core/pom.xml index 85e0267d..cafafa76 100644 --- a/docs-core/pom.xml +++ b/docs-core/pom.xml @@ -51,7 +51,22 @@ commons-lang commons-lang + + + org.apache.commons + commons-email + + + org.freemarker + freemarker + + + + org.glassfish + javax.json + + log4j log4j diff --git a/docs-core/src/main/java/com/sismics/docs/core/constant/Constants.java b/docs-core/src/main/java/com/sismics/docs/core/constant/Constants.java index aba7c82d..9ac980b2 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/constant/Constants.java +++ b/docs-core/src/main/java/com/sismics/docs/core/constant/Constants.java @@ -44,4 +44,24 @@ public class Constants { * Supported document languages. */ public static final List SUPPORTED_LANGUAGES = Lists.newArrayList("eng", "fra", "ita", "deu", "spa", "por", "pol", "rus", "ukr", "ara", "hin", "chi_sim", "chi_tra", "jpn", "tha", "kor"); + + /** + * Base URL environnement variable. + */ + public static final String BASE_URL_ENV = "DOCS_BASE_URL"; + + /** + * Default language environnement variable. + */ + public static final String DEFAULT_LANGUAGE_ENV = "DOCS_DEFAULT_LANGUAGE"; + + /** + * Expiration time of the password recovery in hours. + */ + public static final int PASSWORD_RECOVERY_EXPIRATION_HOUR = 2; + + /** + * Email template for password recovery. + */ + public static final String EMAIL_TEMPLATE_PASSWORD_RECOVERY = "password_recovery"; } diff --git a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/PasswordRecoveryDao.java b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/PasswordRecoveryDao.java new file mode 100644 index 00000000..1eb5bec0 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/PasswordRecoveryDao.java @@ -0,0 +1,68 @@ +package com.sismics.docs.core.dao.jpa; + +import com.sismics.docs.core.constant.Constants; +import com.sismics.docs.core.model.jpa.PasswordRecovery; +import com.sismics.util.context.ThreadLocalContext; +import org.joda.time.DateTime; +import org.joda.time.DurationFieldType; + +import javax.persistence.EntityManager; +import javax.persistence.NoResultException; +import javax.persistence.Query; +import java.util.Date; +import java.util.UUID; + +/** + * Password recovery DAO. + * + * @author jtremeaux + */ +public class PasswordRecoveryDao { + /** + * Create a new password recovery request. + * + * @param passwordRecovery Password recovery + * @return Unique identifier + */ + public String create(PasswordRecovery passwordRecovery) { + passwordRecovery.setId(UUID.randomUUID().toString()); + passwordRecovery.setCreateDate(new Date()); + + EntityManager em = ThreadLocalContext.get().getEntityManager(); + em.persist(passwordRecovery); + + return passwordRecovery.getId(); + } + + /** + * Search an active password recovery by unique identifier. + * + * @param id Unique identifier + * @return Password recovery + */ + public PasswordRecovery getActiveById(String id) { + EntityManager em = ThreadLocalContext.get().getEntityManager(); + try { + Query q = em.createQuery("select r from PasswordRecovery r where r.id = :id and r.createDate > :createDateMin and r.deleteDate is null"); + q.setParameter("id", id); + q.setParameter("createDateMin", new DateTime().withFieldAdded(DurationFieldType.hours(), -1 * Constants.PASSWORD_RECOVERY_EXPIRATION_HOUR).toDate()); + return (PasswordRecovery) q.getSingleResult(); + } catch (NoResultException e) { + return null; + } + } + + /** + * Deletes active password recovery by username. + * + * @param username Username + */ + public void deleteActiveByLogin(String username) { + EntityManager em = ThreadLocalContext.get().getEntityManager(); + Query q = em.createQuery("update PasswordRecovery r set r.deleteDate = :deleteDate where r.username = :username and r.createDate > :createDateMin and r.deleteDate is null"); + q.setParameter("username", username); + q.setParameter("deleteDate", new Date()); + q.setParameter("createDateMin", new DateTime().withFieldAdded(DurationFieldType.hours(), -1 * Constants.PASSWORD_RECOVERY_EXPIRATION_HOUR).toDate()); + q.executeUpdate(); + } +} diff --git a/docs-core/src/main/java/com/sismics/docs/core/event/PasswordLostEvent.java b/docs-core/src/main/java/com/sismics/docs/core/event/PasswordLostEvent.java new file mode 100644 index 00000000..78f39d73 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/docs/core/event/PasswordLostEvent.java @@ -0,0 +1,46 @@ +package com.sismics.docs.core.event; + +import com.google.common.base.MoreObjects; +import com.sismics.docs.core.model.jpa.PasswordRecovery; +import com.sismics.docs.core.model.jpa.User; + +/** + * Event fired on user's password lost event. + * + * @author jtremeaux + */ +public class PasswordLostEvent { + /** + * User. + */ + private User user; + + /** + * Password recovery request. + */ + private PasswordRecovery passwordRecovery; + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public PasswordRecovery getPasswordRecovery() { + return passwordRecovery; + } + + public void setPasswordRecovery(PasswordRecovery passwordRecovery) { + this.passwordRecovery = passwordRecovery; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("user", user) + .add("passwordRecovery", "**hidden**") + .toString(); + } +} diff --git a/docs-core/src/main/java/com/sismics/docs/core/listener/async/PasswordLostAsyncListener.java b/docs-core/src/main/java/com/sismics/docs/core/listener/async/PasswordLostAsyncListener.java new file mode 100644 index 00000000..8c4aa48b --- /dev/null +++ b/docs-core/src/main/java/com/sismics/docs/core/listener/async/PasswordLostAsyncListener.java @@ -0,0 +1,53 @@ +package com.sismics.docs.core.listener.async; + +import com.google.common.eventbus.Subscribe; +import com.sismics.docs.core.constant.Constants; +import com.sismics.docs.core.event.PasswordLostEvent; +import com.sismics.docs.core.model.jpa.PasswordRecovery; +import com.sismics.docs.core.model.jpa.User; +import com.sismics.docs.core.util.TransactionUtil; +import com.sismics.util.EmailUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +/** + * Listener for password recovery requests. + * + * @author jtremeaux + */ +public class PasswordLostAsyncListener { + /** + * Logger. + */ + private static final Logger log = LoggerFactory.getLogger(PasswordLostAsyncListener.class); + + /** + * Handle events. + * + * @param passwordLostEvent Event + */ + @Subscribe + public void onPasswordLost(final PasswordLostEvent passwordLostEvent) { + if (log.isInfoEnabled()) { + log.info("Password lost event: " + passwordLostEvent.toString()); + } + + TransactionUtil.handle(new Runnable() { + @Override + public void run() { + final User user = passwordLostEvent.getUser(); + final PasswordRecovery passwordRecovery = passwordLostEvent.getPasswordRecovery(); + + // Send the password recovery email + Map paramRootMap = new HashMap<>(); + paramRootMap.put("user_name", user.getUsername()); + paramRootMap.put("password_recovery_key", passwordRecovery.getId()); + + EmailUtil.sendEmail(Constants.EMAIL_TEMPLATE_PASSWORD_RECOVERY, user, paramRootMap); + } + }); + } +} diff --git a/docs-core/src/main/java/com/sismics/docs/core/model/context/AppContext.java b/docs-core/src/main/java/com/sismics/docs/core/model/context/AppContext.java index 78a7cb45..b869eee6 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/model/context/AppContext.java +++ b/docs-core/src/main/java/com/sismics/docs/core/model/context/AppContext.java @@ -1,5 +1,16 @@ package com.sismics.docs.core.model.context; +import com.google.common.eventbus.AsyncEventBus; +import com.google.common.eventbus.EventBus; +import com.sismics.docs.core.constant.ConfigType; +import com.sismics.docs.core.dao.jpa.ConfigDao; +import com.sismics.docs.core.listener.async.*; +import com.sismics.docs.core.listener.sync.DeadEventListener; +import com.sismics.docs.core.model.jpa.Config; +import com.sismics.docs.core.service.IndexingService; +import com.sismics.docs.core.util.PdfUtil; +import com.sismics.util.EnvironmentUtil; + import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; @@ -7,19 +18,6 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import com.google.common.eventbus.AsyncEventBus; -import com.google.common.eventbus.EventBus; -import com.lowagie.text.FontFactory; -import com.sismics.docs.core.constant.ConfigType; -import com.sismics.docs.core.dao.jpa.ConfigDao; -import com.sismics.docs.core.event.TemporaryFileCleanupAsyncEvent; -import com.sismics.docs.core.listener.async.*; -import com.sismics.docs.core.listener.sync.DeadEventListener; -import com.sismics.docs.core.model.jpa.Config; -import com.sismics.docs.core.service.IndexingService; -import com.sismics.docs.core.util.PdfUtil; -import com.sismics.util.EnvironmentUtil; - /** * Global application context. * @@ -41,6 +39,11 @@ public class AppContext { */ private EventBus asyncEventBus; + /** + * Asynchronous bus for email sending. + */ + private EventBus mailEventBus; + /** * Indexing service. */ @@ -83,6 +86,9 @@ public class AppContext { asyncEventBus.register(new DocumentDeletedAsyncListener()); asyncEventBus.register(new RebuildIndexAsyncListener()); asyncEventBus.register(new TemporaryFileCleanupAsyncListener()); + + mailEventBus = newAsyncEventBus(); + mailEventBus.register(new PasswordLostAsyncListener()); } /** @@ -138,29 +144,18 @@ public class AppContext { } } - /** - * Getter of eventBus. - * - * @return eventBus - */ public EventBus getEventBus() { return eventBus; } - /** - * Getter of asyncEventBus. - * - * @return asyncEventBus - */ public EventBus getAsyncEventBus() { return asyncEventBus; } - /** - * Getter of indexingService. - * - * @return indexingService - */ + public EventBus getMailEventBus() { + return mailEventBus; + } + public IndexingService getIndexingService() { return indexingService; } diff --git a/docs-core/src/main/java/com/sismics/docs/core/model/jpa/PasswordRecovery.java b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/PasswordRecovery.java new file mode 100644 index 00000000..6306c807 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/PasswordRecovery.java @@ -0,0 +1,82 @@ +package com.sismics.docs.core.model.jpa; + +import com.google.common.base.MoreObjects; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.Date; + +/** + * Password recovery entity. + * + * @author jtremeaux + */ +@Entity +@Table(name = "T_PASSWORD_RECOVERY") +public class PasswordRecovery { + /** + * Identifier. + */ + @Id + @Column(name = "PWR_ID_C", length = 36) + private String id; + + /** + * Username. + */ + @Column(name = "PWR_USERNAME_C", nullable = false, length = 50) + private String username; + + /** + * Creation date. + */ + @Column(name = "PWR_CREATEDATE_D", nullable = false) + private Date createDate; + + /** + * Delete date. + */ + @Column(name = "PWR_DELETEDATE_D") + private Date deleteDate; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Date getCreateDate() { + return createDate; + } + + public void setCreateDate(Date createDate) { + this.createDate = createDate; + } + + public Date getDeleteDate() { + return deleteDate; + } + + public void setDeleteDate(Date deleteDate) { + this.deleteDate = deleteDate; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .toString(); + } +} diff --git a/docs-core/src/main/java/com/sismics/docs/core/util/AuditLogUtil.java b/docs-core/src/main/java/com/sismics/docs/core/util/AuditLogUtil.java index d2d0913c..60b04820 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/util/AuditLogUtil.java +++ b/docs-core/src/main/java/com/sismics/docs/core/util/AuditLogUtil.java @@ -22,6 +22,10 @@ public class AuditLogUtil { * @param userId User ID */ public static void create(Loggable loggable, AuditLogType type, String userId) { + if (userId == null) { + userId = "admin"; + } + // Get the entity ID EntityManager em = ThreadLocalContext.get().getEntityManager(); String entityId = (String) em.getEntityManagerFactory().getPersistenceUnitUtil().getIdentifier(loggable); diff --git a/docs-core/src/main/java/com/sismics/docs/core/util/UserUtil.java b/docs-core/src/main/java/com/sismics/docs/core/util/UserUtil.java deleted file mode 100644 index 7f19c0d1..00000000 --- a/docs-core/src/main/java/com/sismics/docs/core/util/UserUtil.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.sismics.docs.core.util; - -import com.sismics.docs.core.model.jpa.User; - -/** - * Utilitaires sur les utilisateurs. - * - * @author jtremeaux - */ -public class UserUtil { - /** - * Retourne the user's username. - * - * @param user User - * @return User name - */ - public static String getUserName(User user) { - return user.getUsername(); - } -} diff --git a/docs-core/src/main/java/com/sismics/util/EmailUtil.java b/docs-core/src/main/java/com/sismics/util/EmailUtil.java new file mode 100644 index 00000000..13ddefbc --- /dev/null +++ b/docs-core/src/main/java/com/sismics/util/EmailUtil.java @@ -0,0 +1,125 @@ +package com.sismics.util; + +import com.google.common.base.Strings; +import com.sismics.docs.core.constant.ConfigType; +import com.sismics.docs.core.constant.Constants; +import com.sismics.docs.core.dao.jpa.ConfigDao; +import com.sismics.docs.core.model.jpa.Config; +import com.sismics.docs.core.model.jpa.User; +import com.sismics.docs.core.util.ConfigUtil; +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapperBuilder; +import freemarker.template.Template; +import org.apache.commons.mail.HtmlEmail; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonReader; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.Locale; +import java.util.Map; + +/** + * Emails utilities. + * + * @author jtremeaux + */ +public class EmailUtil { + /** + * Logger. + */ + private static final Logger log = LoggerFactory.getLogger(EmailUtil.class); + + /** + * Returns an email content as string. + * The content is formatted from the given Freemarker template and parameters. + * + * @param templateName Template name + * @param paramRootMap Map of Freemarker parameters + * @param locale Locale + * @return Template as string + * @throws Exception e + */ + private static String getFormattedHtml(String templateName, Map paramRootMap, Locale locale) throws Exception { + Configuration cfg = new Configuration(Configuration.VERSION_2_3_23); + cfg.setClassForTemplateLoading(EmailUtil.class, "/email_template"); + cfg.setObjectWrapper(new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_23).build()); + Template template = cfg.getTemplate(templateName + "/template.ftl"); + paramRootMap.put("messages", new ResourceBundleModel(MessageUtil.getMessage(locale), + new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_23).build())); + StringWriter sw = new StringWriter(); + template.process(paramRootMap, sw); + + return sw.toString(); + } + + /** + * Sending an email to a user. + * + * @param templateName Template name + * @param recipientUser Recipient user + * @param subject Email subject + * @param paramMap Email parameters + */ + public static void sendEmail(String templateName, User recipientUser, String subject, Map paramMap) { + if (log.isInfoEnabled()) { + log.info("Sending email from template=" + templateName + " to user " + recipientUser); + } + + try { + // Build email headers + HtmlEmail email = new HtmlEmail(); + email.setCharset("UTF-8"); + email.setHostName(ConfigUtil.getConfigStringValue(ConfigType.SMTP_HOSTNAME)); + email.setSmtpPort(ConfigUtil.getConfigIntegerValue(ConfigType.SMTP_PORT)); + email.addTo(recipientUser.getEmail(), recipientUser.getUsername()); + ConfigDao configDao = new ConfigDao(); + Config themeConfig = configDao.getById(ConfigType.THEME); + String appName = "Sismics Docs"; + if (themeConfig != null) { + try (JsonReader reader = Json.createReader(new StringReader(themeConfig.getValue()))) { + JsonObject themeJson = reader.readObject(); + appName = themeJson.getString("name", "Sismics Docs"); + } + } + email.setFrom(ConfigUtil.getConfigStringValue(ConfigType.SMTP_FROM), appName); + java.util.Locale userLocale = LocaleUtil.getLocale(System.getenv(Constants.DEFAULT_LANGUAGE_ENV)); + email.setSubject(subject); + email.setTextMsg(MessageUtil.getMessage(userLocale, "email.no_html.error")); + + // Add automatic parameters + String baseUrl = System.getenv(Constants.BASE_URL_ENV); + if (Strings.isNullOrEmpty(baseUrl)) { + log.error("DOCS_BASE_URL environnement variable needs to be set for proper email links"); + baseUrl = ""; // At least the mail will be sent... + } + paramMap.put("base_url", baseUrl); + paramMap.put("app_name", appName); + + // Build HTML content from Freemarker template + String htmlEmailTemplate = getFormattedHtml(templateName, paramMap, userLocale); + email.setHtmlMsg(htmlEmailTemplate); + + // Send the email + email.send(); + } catch (Exception e) { + log.error("Error sending email with template=" + templateName + " to user " + recipientUser, e); + } + } + + /** + * Sending an email to a user. + * + * @param templateName Template name + * @param recipientUser Recipient user + * @param paramMap Email parameters + */ + public static void sendEmail(String templateName, User recipientUser, Map paramMap) { + java.util.Locale userLocale = LocaleUtil.getLocale(System.getenv(Constants.DEFAULT_LANGUAGE_ENV)); + String subject = MessageUtil.getMessage(userLocale, "email.template." + templateName + ".subject"); + sendEmail(templateName, recipientUser, subject, paramMap); + } +} diff --git a/docs-core/src/main/java/com/sismics/util/LocaleUtil.java b/docs-core/src/main/java/com/sismics/util/LocaleUtil.java new file mode 100644 index 00000000..11ab9c7b --- /dev/null +++ b/docs-core/src/main/java/com/sismics/util/LocaleUtil.java @@ -0,0 +1,36 @@ +package com.sismics.util; + +import com.google.common.base.Strings; + +import java.util.Locale; + +/** + * Locale utilities. + * + * @author jtremeaux + */ +public class LocaleUtil { + /** + * Returns a locale from the language / country / variation code (ex: fr_FR). + * + * @param localeCode Locale code + * @return Locale instance + */ + public static Locale getLocale(String localeCode) { + if (Strings.isNullOrEmpty(localeCode)) { + return Locale.ENGLISH; + } + + String[] localeCodeArray = localeCode.split("_"); + String language = localeCodeArray[0]; + String country = ""; + String variant = ""; + if (localeCodeArray.length >= 2) { + country = localeCodeArray[1]; + } + if (localeCodeArray.length >= 3) { + variant = localeCodeArray[2]; + } + return new Locale(language, country, variant); + } +} diff --git a/docs-core/src/main/java/com/sismics/util/MessageUtil.java b/docs-core/src/main/java/com/sismics/util/MessageUtil.java new file mode 100644 index 00000000..a2898bd0 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/util/MessageUtil.java @@ -0,0 +1,43 @@ +package com.sismics.util; + +import java.text.MessageFormat; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +/** + * Messages utilities. + * + * @author jtremeaux + */ +public class MessageUtil { + /** + * Returns a localized message in the specified language. + * Returns **key** if no message exists for this key. + * + * @param locale Locale + * @param key Message key + * @param args Arguments to format + * @return Formatted message + */ + public static String getMessage(Locale locale, String key, Object... args) { + ResourceBundle resources = ResourceBundle.getBundle("messages", locale); + String message; + try { + message = resources.getString(key); + } catch (MissingResourceException e) { + message = "**" + key + "**"; + } + return MessageFormat.format(message, args); + } + + /** + * Returns the resource bundle corresponding to the specified language. + * + * @param locale Locale + * @return Resource bundle + */ + public static ResourceBundle getMessage(Locale locale) { + return ResourceBundle.getBundle("messages", locale); + } +} diff --git a/docs-core/src/main/java/com/sismics/util/ResourceBundleModel.java b/docs-core/src/main/java/com/sismics/util/ResourceBundleModel.java new file mode 100644 index 00000000..cff2a583 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/util/ResourceBundleModel.java @@ -0,0 +1,55 @@ +package com.sismics.util; + +import freemarker.ext.beans.BeansWrapper; +import freemarker.ext.beans.StringModel; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; + +import java.util.Iterator; +import java.util.List; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +/** + * Override of {@link freemarker.ext.beans.ResourceBundleModel} + * to threat single quotes uniformely. + * + * @author bgamard + */ +public class ResourceBundleModel extends freemarker.ext.beans.ResourceBundleModel { + + /** + * Default constructor. + * + * @param bundle Resource bundle + * @param wrapper Beans wrapper + */ + public ResourceBundleModel(ResourceBundle bundle, BeansWrapper wrapper) { + super(bundle, wrapper); + } + + @SuppressWarnings("rawtypes") + @Override + public Object exec(List arguments) throws TemplateModelException { + // Must have at least one argument - the key + if (arguments.size() < 1) + throw new TemplateModelException("No message key was specified"); + // Read it + Iterator it = arguments.iterator(); + String key = unwrap((TemplateModel) it.next()).toString(); + try { + // Copy remaining arguments into an Object[] + int args = arguments.size() - 1; + Object[] params = new Object[args]; + for (int i = 0; i < args; ++i) + params[i] = unwrap((TemplateModel) it.next()); + + // Invoke format + return new StringModel(format(key, params), wrapper); + } catch (MissingResourceException e) { + throw new TemplateModelException("No such key: " + key); + } catch (Exception e) { + throw new TemplateModelException(e.getMessage()); + } + } +} diff --git a/docs-core/src/main/resources/META-INF/persistence.xml b/docs-core/src/main/resources/META-INF/persistence.xml index 7c40e310..b62e7679 100644 --- a/docs-core/src/main/resources/META-INF/persistence.xml +++ b/docs-core/src/main/resources/META-INF/persistence.xml @@ -4,6 +4,6 @@ xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd" version="2.0"> - org.hibernate.ejb.HibernatePersistence + org.hibernate.jpa.HibernatePersistenceProvider \ No newline at end of file diff --git a/docs-core/src/main/resources/config.properties b/docs-core/src/main/resources/config.properties index a56b42a4..ca77caa4 100644 --- a/docs-core/src/main/resources/config.properties +++ b/docs-core/src/main/resources/config.properties @@ -1 +1 @@ -db.version=12 \ No newline at end of file +db.version=13 \ No newline at end of file diff --git a/docs-core/src/main/resources/db/update/dbupdate-013-0.sql b/docs-core/src/main/resources/db/update/dbupdate-013-0.sql new file mode 100644 index 00000000..1407d548 --- /dev/null +++ b/docs-core/src/main/resources/db/update/dbupdate-013-0.sql @@ -0,0 +1,2 @@ +create cached table T_PASSWORD_RECOVERY ( PWR_ID_C varchar(36) not null, PWR_USERNAME_C varchar(50) not null, PWR_CREATEDATE_D datetime, PWR_DELETEDATE_D datetime, primary key (PWR_ID_C) ); +update T_CONFIG set CFG_VALUE_C = '13' where CFG_ID_C = 'DB_VERSION'; diff --git a/docs-core/src/main/resources/email_template/layout.ftl b/docs-core/src/main/resources/email_template/layout.ftl new file mode 100644 index 00000000..74a8dd09 --- /dev/null +++ b/docs-core/src/main/resources/email_template/layout.ftl @@ -0,0 +1,16 @@ +<#macro email> + + + + + + + +
+ ${base_url} +
+
+ <#nested> +
+
+ \ No newline at end of file diff --git a/docs-core/src/main/resources/email_template/password_recovery/template.ftl b/docs-core/src/main/resources/email_template/password_recovery/template.ftl new file mode 100644 index 00000000..84c371fa --- /dev/null +++ b/docs-core/src/main/resources/email_template/password_recovery/template.ftl @@ -0,0 +1,8 @@ +<#import "../layout.ftl" as layout> +<@layout.email> +

${app_name} - ${messages['email.template.password_recovery.subject']}

+

${messages('email.template.password_recovery.hello', user_name)}

+

${messages['email.template.password_recovery.instruction1']}

+

${messages['email.template.password_recovery.instruction2']}

+ ${messages['email.template.password_recovery.click_here']} + \ No newline at end of file diff --git a/docs-core/src/main/resources/messages.properties b/docs-core/src/main/resources/messages.properties new file mode 100644 index 00000000..fc8d7a39 --- /dev/null +++ b/docs-core/src/main/resources/messages.properties @@ -0,0 +1,5 @@ +email.template.password_recovery.subject=Please reset your password +email.template.password_recovery.hello=Hello {0}. +email.template.password_recovery.instruction1=We have received a request to reset your password.
If you did not request help, then feel free to ignore this email. +email.template.password_recovery.instruction2=To reset your password, please visit the link below: +email.template.password_recovery.click_here=Click here to reset your password \ No newline at end of file diff --git a/docs-core/src/main/resources/messages_fr.properties b/docs-core/src/main/resources/messages_fr.properties new file mode 100644 index 00000000..6ddc4b41 --- /dev/null +++ b/docs-core/src/main/resources/messages_fr.properties @@ -0,0 +1,5 @@ +email.template.password_recovery.subject=Réinitialiser votre mot de passe +email.template.password_recovery.hello=Bonjour {0}. +email.template.password_recovery.instruction1=Nous avons reçu une demande de réinitialisation de mot de passe.
Si vous n''avez rien demandé, vous pouvez ignorer cet mail. +email.template.password_recovery.instruction2=Pour réinitialiser votre mot de passe, cliquez sur le lien ci-dessous : +email.template.password_recovery.click_here=Cliquez ici pour réinitialiser votre mot de passe. \ No newline at end of file diff --git a/docs-web-common/pom.xml b/docs-web-common/pom.xml index 5a7e8a30..efa9aced 100644 --- a/docs-web-common/pom.xml +++ b/docs-web-common/pom.xml @@ -93,7 +93,13 @@ jersey-container-grizzly2-servlet test
- + + + org.subethamail + subethasmtp-wiser + test + + diff --git a/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java b/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java index a344443d..0d00e9a4 100644 --- a/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java +++ b/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java @@ -1,11 +1,9 @@ package com.sismics.docs.rest; -import java.net.URI; - -import javax.ws.rs.core.Application; -import javax.ws.rs.core.UriBuilder; - +import com.sismics.docs.rest.util.ClientUtil; import com.sismics.util.filter.HeaderBasedSecurityFilter; +import com.sismics.util.filter.RequestContextFilter; +import com.sismics.util.filter.TokenBasedSecurityFilter; import org.glassfish.grizzly.http.server.HttpServer; import org.glassfish.grizzly.servlet.ServletRegistration; import org.glassfish.grizzly.servlet.WebappContext; @@ -17,10 +15,17 @@ import org.glassfish.jersey.test.spi.TestContainerException; import org.glassfish.jersey.test.spi.TestContainerFactory; import org.junit.After; import org.junit.Before; +import org.subethamail.wiser.Wiser; +import org.subethamail.wiser.WiserMessage; -import com.sismics.docs.rest.util.ClientUtil; -import com.sismics.util.filter.RequestContextFilter; -import com.sismics.util.filter.TokenBasedSecurityFilter; +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; +import javax.ws.rs.core.Application; +import javax.ws.rs.core.UriBuilder; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.util.List; /** * Base class of integration tests with Jersey. @@ -37,6 +42,11 @@ public abstract class BaseJerseyTest extends JerseyTest { * Utility class for the REST client. */ protected ClientUtil clientUtil; + + /** + * Test mail server. + */ + private Wiser wiser; @Override protected TestContainerFactory getTestContainerFactory() throws TestContainerException { @@ -66,7 +76,11 @@ public abstract class BaseJerseyTest extends JerseyTest { System.setProperty("docs.header_authentication", "true"); clientUtil = new ClientUtil(target()); - + + wiser = new Wiser(); + wiser.setPort(2500); + wiser.start(); + httpServer = HttpServer.createSimpleServer(getClass().getResource("/").getFile(), "localhost", getPort()); WebappContext context = new WebappContext("GrizzlyContext", "/docs"); context.addFilter("requestContextFilter", RequestContextFilter.class) @@ -86,6 +100,26 @@ public abstract class BaseJerseyTest extends JerseyTest { httpServer.start(); } + /** + * Extract an email from the list and consume it. + * + * @return Texte de l'email + * @throws MessagingException e + * @throws IOException e + */ + protected String popEmail() throws MessagingException, IOException { + List wiserMessageList = wiser.getMessages(); + if (wiserMessageList.isEmpty()) { + return null; + } + WiserMessage wiserMessage = wiserMessageList.get(wiserMessageList.size() - 1); + wiserMessageList.remove(wiserMessageList.size() - 1); + MimeMessage message = wiserMessage.getMimeMessage(); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + message.writeTo(os); + return os.toString(); + } + @Override @After public void tearDown() throws Exception { diff --git a/docs-web/pom.xml b/docs-web/pom.xml index 3573646e..4d239548 100644 --- a/docs-web/pom.xml +++ b/docs-web/pom.xml @@ -121,6 +121,12 @@ jersey-container-grizzly2-servlet test + + + org.subethamail + subethasmtp-wiser + test + diff --git a/docs-web/src/dev/resources/config.properties b/docs-web/src/dev/resources/config.properties index 17c44aa4..b41be0b3 100644 --- a/docs-web/src/dev/resources/config.properties +++ b/docs-web/src/dev/resources/config.properties @@ -1,3 +1,3 @@ api.current_version=${project.version} api.min_version=1.0 -db.version=12 \ No newline at end of file +db.version=13 \ No newline at end of file diff --git a/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java b/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java index aaca4646..1226fa27 100644 --- a/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java +++ b/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java @@ -11,6 +11,8 @@ import com.sismics.docs.core.dao.jpa.dto.GroupDto; import com.sismics.docs.core.dao.jpa.dto.UserDto; import com.sismics.docs.core.event.DocumentDeletedAsyncEvent; import com.sismics.docs.core.event.FileDeletedAsyncEvent; +import com.sismics.docs.core.event.PasswordLostEvent; +import com.sismics.docs.core.model.context.AppContext; import com.sismics.docs.core.model.jpa.*; import com.sismics.docs.core.util.ConfigUtil; import com.sismics.docs.core.util.EncryptionUtil; @@ -901,7 +903,110 @@ public class UserResource extends BaseResource { .add("status", "ok"); return Response.ok().entity(response.build()).build(); } - + + /** + * Create a key to reset a password and send it by email. + * + * @api {post} /user/password_lost Create a key to reset a password and send it by email + * @apiName PostUserPasswordLost + * @apiGroup User + * @apiParam {String} username Username + * @apiSuccess {String} status Status OK + * @apiError (client) UserNotFound The user is not found + * @apiError (client) ValidationError Validation error + * @apiPermission none + * @apiVersion 1.5.0 + * + * @param username Username + * @return Response + */ + @POST + @Path("password_lost") + @Produces(MediaType.APPLICATION_JSON) + public Response passwordLost(@FormParam("username") String username) { + authenticate(); + + // Validate input data + ValidationUtil.validateStringNotBlank("username", username); + + // Check for user existence + UserDao userDao = new UserDao(); + User user = userDao.getActiveByUsername(username); + if (user == null) { + throw new ClientException("UserNotFound", "User not found: " + username); + } + + // Create the password recovery key + PasswordRecoveryDao passwordRecoveryDao = new PasswordRecoveryDao(); + PasswordRecovery passwordRecovery = new PasswordRecovery(); + passwordRecovery.setUsername(user.getUsername()); + passwordRecoveryDao.create(passwordRecovery); + + // Fire a password lost event + PasswordLostEvent passwordLostEvent = new PasswordLostEvent(); + passwordLostEvent.setUser(user); + passwordLostEvent.setPasswordRecovery(passwordRecovery); + AppContext.getInstance().getMailEventBus().post(passwordLostEvent); + + // Always return OK + JsonObjectBuilder response = Json.createObjectBuilder() + .add("status", "ok"); + return Response.ok().entity(response.build()).build(); + } + + /** + * Reset the user's password. + * + * @api {post} /user/password_reset Reset the user's password + * @apiName PostUserPasswordReset + * @apiGroup User + * @apiParam {String} key Password recovery key + * @apiParam {String} password New password + * @apiSuccess {String} status Status OK + * @apiError (client) KeyNotFound Password recovery key not found + * @apiError (client) ValidationError Validation error + * @apiPermission none + * @apiVersion 1.5.0 + * + * @param passwordResetKey Password reset key + * @param password New password + * @return Response + */ + @POST + @Path("password_reset") + @Produces(MediaType.APPLICATION_JSON) + public Response passwordReset( + @FormParam("key") String passwordResetKey, + @FormParam("password") String password) { + authenticate(); + + // Validate input data + ValidationUtil.validateRequired("key", passwordResetKey); + password = ValidationUtil.validateLength(password, "password", 8, 50, true); + + // Load the password recovery key + PasswordRecoveryDao passwordRecoveryDao = new PasswordRecoveryDao(); + PasswordRecovery passwordRecovery = passwordRecoveryDao.getActiveById(passwordResetKey); + if (passwordRecovery == null) { + throw new ClientException("KeyNotFound", "Password recovery key not found"); + } + + UserDao userDao = new UserDao(); + User user = userDao.getActiveByUsername(passwordRecovery.getUsername()); + + // Change the password + user.setPassword(password); + user = userDao.updatePassword(user, principal.getId()); + + // Deletes password recovery requests + passwordRecoveryDao.deleteActiveByLogin(user.getUsername()); + + // Always return OK + JsonObjectBuilder response = Json.createObjectBuilder() + .add("status", "ok"); + return Response.ok().entity(response.build()).build(); + } + /** * Returns the authentication token value. * diff --git a/docs-web/src/prod/resources/config.properties b/docs-web/src/prod/resources/config.properties index 17c44aa4..b41be0b3 100644 --- a/docs-web/src/prod/resources/config.properties +++ b/docs-web/src/prod/resources/config.properties @@ -1,3 +1,3 @@ api.current_version=${project.version} api.min_version=1.0 -db.version=12 \ No newline at end of file +db.version=13 \ No newline at end of file diff --git a/docs-web/src/stress/resources/config.properties b/docs-web/src/stress/resources/config.properties index 17c44aa4..b41be0b3 100644 --- a/docs-web/src/stress/resources/config.properties +++ b/docs-web/src/stress/resources/config.properties @@ -1,3 +1,3 @@ api.current_version=${project.version} api.min_version=1.0 -db.version=12 \ No newline at end of file +db.version=13 \ No newline at end of file diff --git a/docs-web/src/test/java/com/sismics/docs/rest/TestUserResource.java b/docs-web/src/test/java/com/sismics/docs/rest/TestUserResource.java index 4d5bdfa7..6cddb206 100644 --- a/docs-web/src/test/java/com/sismics/docs/rest/TestUserResource.java +++ b/docs-web/src/test/java/com/sismics/docs/rest/TestUserResource.java @@ -1,5 +1,6 @@ package com.sismics.docs.rest; +import com.sismics.docs.core.model.context.AppContext; import com.sismics.util.filter.TokenBasedSecurityFilter; import com.sismics.util.totp.GoogleAuthenticator; import org.junit.Assert; @@ -13,6 +14,8 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import java.util.Date; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Exhaustive test of the user resource. @@ -354,4 +357,79 @@ public class TestUserResource extends BaseJerseyTest { .get(JsonObject.class); Assert.assertFalse(json.getBoolean("totp_enabled")); } + + @Test + public void testResetPassword() throws Exception { + // Login admin + String adminToken = clientUtil.login("admin", "admin", false); + + // Change SMTP configuration to target Wiser + target().path("/app/config_smtp").request() + .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) + .post(Entity.form(new Form() + .param("hostname", "localhost") + .param("port", "2500") + .param("from", "contact@sismicsdocs.com") + ), JsonObject.class); + + // Create absent_minded who lost his password + clientUtil.createUser("absent_minded"); + + // User no_such_user try to recovery its password: invalid user + Response response = target().path("/user/password_lost").request() + .post(Entity.form(new Form() + .param("username", "no_such_user"))); + Assert.assertEquals(Response.Status.BAD_REQUEST, Response.Status.fromStatusCode(response.getStatus())); + JsonObject json = response.readEntity(JsonObject.class); + Assert.assertEquals("UserNotFound", json.getString("type")); + + // User absent_minded try to recovery its password: OK + json = target().path("/user/password_lost").request() + .post(Entity.form(new Form() + .param("username", "absent_minded")), JsonObject.class); + Assert.assertEquals("ok", json.getString("status")); + AppContext.getInstance().waitForAsync(); + String emailBody = popEmail(); + Assert.assertNotNull("No email to consume", emailBody); + Assert.assertTrue(emailBody.contains("Please reset your password")); + Pattern keyPattern = Pattern.compile("/passwordreset/(.+?)\""); + Matcher keyMatcher = keyPattern.matcher(emailBody); + Assert.assertTrue("Token not found", keyMatcher.find()); + String key = keyMatcher.group(1).replaceAll("=", ""); + + // User absent_minded resets its password: invalid key + response = target().path("/user/password_reset").request() + .post(Entity.form(new Form() + .param("key", "no_such_key") + .param("password", "87654321"))); + Assert.assertEquals(Response.Status.BAD_REQUEST, Response.Status.fromStatusCode(response.getStatus())); + json = response.readEntity(JsonObject.class); + Assert.assertEquals("KeyNotFound", json.getString("type")); + + // User absent_minded resets its password: password invalid + response = target().path("/user/password_reset").request() + .post(Entity.form(new Form() + .param("key", key) + .param("password", " 1 "))); + Assert.assertEquals(Response.Status.BAD_REQUEST, Response.Status.fromStatusCode(response.getStatus())); + json = response.readEntity(JsonObject.class); + Assert.assertEquals("ValidationError", json.getString("type")); + Assert.assertTrue(json.getString("message"), json.getString("message").contains("password")); + + // User absent_minded resets its password: OK + json = target().path("/user/password_reset").request() + .post(Entity.form(new Form() + .param("key", key) + .param("password", "87654321")), JsonObject.class); + Assert.assertEquals("ok", json.getString("status")); + + // User absent_minded resets its password: expired key + response = target().path("/user/password_reset").request() + .post(Entity.form(new Form() + .param("key", key) + .param("password", "87654321"))); + Assert.assertEquals(Response.Status.BAD_REQUEST, Response.Status.fromStatusCode(response.getStatus())); + json = response.readEntity(JsonObject.class); + Assert.assertEquals("KeyNotFound", json.getString("type")); + } } \ No newline at end of file diff --git a/pom.xml b/pom.xml index b3cadb17..4059a000 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,8 @@ 1.10 2.6 2.4 + 1.5 + 2.3.23 1.4 19.0 1.2.16 @@ -27,6 +29,7 @@ 4.12 1.4.191 2.22.2 + 1.0.4 0.3m 5.5.0 4.2 @@ -40,6 +43,7 @@ 3.2.1 1.6.5 1.3.1 + 1.2 9.2.13.v20150730 9.2.13.v20150730 @@ -193,6 +197,12 @@ commons-io ${commons-io.commons-io.version} + + + org.apache.commons + commons-email + ${org.apache.commons.commons-email.version} + com.google.guava @@ -284,7 +294,13 @@ jersey-container-grizzly2-servlet ${org.glassfish.jersey.version} - + + + org.glassfish + javax.json + ${org.glassfish.javax.json.version} + + com.h2database h2 @@ -314,7 +330,13 @@ commons-dbcp ${commons-dbcp.version} - + + + org.freemarker + freemarker + ${org.freemarker.freemarker.version} + + joda-time joda-time @@ -375,6 +397,12 @@ ${fr.opensagres.xdocreport.version} + + org.subethamail + subethasmtp-wiser + ${org.subethamail.subethasmtp-wiser.version} + + com.twelvemonkeys.servlet