1
0
mirror of https://github.com/sismics/docs.git synced 2025-12-15 02:36:24 +00:00

#161: password recovery by email (wip, server part done)

This commit is contained in:
Benjamin Gamard
2017-11-17 22:03:54 +01:00
parent b8176a9fe9
commit 039d881a07
29 changed files with 881 additions and 66 deletions

View File

@@ -51,7 +51,22 @@
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>javax.json</artifactId>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>

View File

@@ -44,4 +44,24 @@ public class Constants {
* Supported document languages.
*/
public static final List<String> 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";
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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<String, Object> 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);
}
});
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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">
<persistence-unit name="transactions-optional" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.ejb.HibernatePersistence</provider>
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
</persistence-unit>
</persistence>

View File

@@ -1 +1 @@
db.version=12
db.version=13

View File

@@ -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';

View File

@@ -0,0 +1,16 @@
<#macro email>
<table style="width: 100%; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';">
<tr style="background: #242424; color: #fff;">
<td style="padding: 12px; font-size: 16px; font-weight: bold;">
${base_url}
</td>
</tr>
<tr>
<td style="padding-bottom: 10px; padding-top: 10px;">
<div style="border: 1px solid #ddd; padding: 10px;">
<#nested>
</div>
</td>
</tr>
</table>
</#macro>

View File

@@ -0,0 +1,8 @@
<#import "../layout.ftl" as layout>
<@layout.email>
<h2>${app_name} - ${messages['email.template.password_recovery.subject']}</h2>
<p>${messages('email.template.password_recovery.hello', user_name)}</p>
<p>${messages['email.template.password_recovery.instruction1']}</p>
<p>${messages['email.template.password_recovery.instruction2']}</p>
<a href="${base_url}/#/passwordreset/${password_recovery_key}">${messages['email.template.password_recovery.click_here']}</a>
</@layout.email>

View File

@@ -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.<br/>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

View File

@@ -0,0 +1,5 @@
email.template.password_recovery.subject=R<EFBFBD>initialiser votre mot de passe
email.template.password_recovery.hello=Bonjour {0}.
email.template.password_recovery.instruction1=Nous avons re<72>u une demande de r<>initialisation de mot de passe.<br/>Si vous n''avez rien demand<6E>, 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.