package edu.harvard.iq.dataverse.passwordreset; import edu.harvard.iq.dataverse.MailServiceBean; import edu.harvard.iq.dataverse.authorization.providers.builtin.PasswordEncryption; import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinUser; import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinUserServiceBean; import edu.harvard.iq.dataverse.util.SystemConfig; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import javax.ejb.EJB; import javax.ejb.Stateless; import javax.inject.Named; import javax.persistence.EntityManager; import javax.persistence.NoResultException; import javax.persistence.NonUniqueResultException; import javax.persistence.PersistenceContext; import javax.persistence.TypedQuery; @Stateless @Named public class PasswordResetServiceBean { private static final Logger logger = Logger.getLogger(PasswordResetServiceBean.class.getCanonicalName()); @EJB BuiltinUserServiceBean dataverseUserService; @EJB MailServiceBean mailService; @PersistenceContext(unitName = "VDCNet-ejbPU") private EntityManager em; /** * Initiate the password reset process. * * @param emailAddress * @return {@link PasswordResetInitResponse} * @throws edu.harvard.iq.dataverse.passwordreset.PasswordResetException */ // inspired by Troy Hunt: Everything you ever wanted to know about building a secure password reset feature - http://www.troyhunt.com/2012/05/everything-you-ever-wanted-to-know.html public PasswordResetInitResponse requestReset(String emailAddress) throws PasswordResetException { deleteAllExpiredTokens(); BuiltinUser user = dataverseUserService.findByEmail(emailAddress); if (user != null) { return requestPasswordReset( user, true, PasswordResetData.Reason.FORGOT_PASSWORD ); } else { return new PasswordResetInitResponse(false); } } public PasswordResetInitResponse requestPasswordReset( BuiltinUser aUser, boolean sendEmail, PasswordResetData.Reason reason ) throws PasswordResetException { // delete old tokens for the user List<PasswordResetData> oldTokens = findPasswordResetDataByDataverseUser(aUser); for (PasswordResetData oldToken : oldTokens) { em.remove(oldToken); } // create a fresh token for the user PasswordResetData passwordResetData = new PasswordResetData(aUser); passwordResetData.setReason(reason); try { em.persist(passwordResetData); PasswordResetInitResponse passwordResetInitResponse = new PasswordResetInitResponse(true, passwordResetData); if ( sendEmail ) { sendPasswordResetEmail(aUser, passwordResetInitResponse.getResetUrl()); } return passwordResetInitResponse; } catch (Exception ex) { String msg = "Unable to save token for " + aUser.getEmail(); throw new PasswordResetException(msg, ex); } } private void sendPasswordResetEmail(BuiltinUser aUser, String passwordResetUrl) throws PasswordResetException { String messageBody = "Hi " + aUser.getDisplayName() + ",\n\n" + "Someone, hopefully you, requested a password reset for " + aUser.getUserName() + ".\n\n" + "Please click the link below to reset your Dataverse account password:\n\n" + passwordResetUrl + "\n\n" + "The link above will only work for the next " + SystemConfig.getMinutesUntilPasswordResetTokenExpires() + " minutes.\n\n" /** * @todo It would be a nice touch to show the IP from * which the password reset originated. */ + "Please contact us if you did not request this password reset or need further help.\n\n"; try { String toAddress = aUser.getEmail(); String subject = "Dataverse Password Reset Requested"; mailService.sendSystemEmail(toAddress, subject, messageBody); } catch (Exception ex) { /** * @todo get more specific about the exception that's thrown * when `asadmin create-javamail-resource` (or equivalent) * hasn't been run. */ throw new PasswordResetException("Problem sending password reset email possibily due to mail server not being configured."); } logger.log(Level.INFO, "attempted to send mail to {0}", aUser.getEmail()); } /** * Process the password reset token, allowing the user to reset the password * or report on a invalid token. * * @param tokenQueried */ public PasswordResetExecResponse processToken(String tokenQueried) { deleteAllExpiredTokens(); PasswordResetExecResponse tokenUnusable = new PasswordResetExecResponse(tokenQueried, null); PasswordResetData passwordResetData = findSinglePasswordResetDataByToken(tokenQueried); if (passwordResetData != null) { if (passwordResetData.isExpired()) { // shouldn't reach here since tokens are being expired above return tokenUnusable; } else { PasswordResetExecResponse goodTokenCanProceed = new PasswordResetExecResponse(tokenQueried, passwordResetData); return goodTokenCanProceed; } } else { return tokenUnusable; } } /** * @param token * @return Null or a single row of password reset data. */ private PasswordResetData findSinglePasswordResetDataByToken(String token) { PasswordResetData passwordResetData = null; TypedQuery<PasswordResetData> typedQuery = em.createNamedQuery("PasswordResetData.findByToken", PasswordResetData.class); typedQuery.setParameter("token", token); try { passwordResetData = typedQuery.getSingleResult(); } catch (NoResultException | NonUniqueResultException ex) { logger.info("When looking up " + token + " caught " + ex); } return passwordResetData; } public List<PasswordResetData> findPasswordResetDataByDataverseUser(BuiltinUser user) { TypedQuery<PasswordResetData> typedQuery = em.createNamedQuery("PasswordResetData.findByUser", PasswordResetData.class); typedQuery.setParameter("user", user); List<PasswordResetData> passwordResetDatas = typedQuery.getResultList(); return passwordResetDatas; } public List<PasswordResetData> findAllPasswordResetData() { TypedQuery<PasswordResetData> typedQuery = em.createNamedQuery("PasswordResetData.findAll", PasswordResetData.class); List<PasswordResetData> passwordResetDatas = typedQuery.getResultList(); return passwordResetDatas; } /** * @return The number of tokens deleted. */ private long deleteAllExpiredTokens() { long numDeleted = 0; List<PasswordResetData> allData = findAllPasswordResetData(); for (PasswordResetData data : allData) { if (data.isExpired()) { em.remove(data); numDeleted++; } } return numDeleted; } public PasswordChangeAttemptResponse attemptPasswordReset(BuiltinUser user, String newPassword, String token) { final String messageSummarySuccess = "Password Reset Successfully"; final String messageDetailSuccess = ""; // optimistic defaults :) String messageSummary = messageSummarySuccess; String messageDetail = messageDetailSuccess; final String messageSummaryFail = "Password Reset Problem"; if (user == null) { messageSummary = messageSummaryFail; messageDetail = "User could not be found."; return new PasswordChangeAttemptResponse(false, messageSummary, messageDetail); } if (newPassword == null) { messageSummary = messageSummaryFail; messageDetail = "New password not provided."; return new PasswordChangeAttemptResponse(false, messageSummary, messageDetail); } if (token == null) { logger.info("No token provided... won't be able to delete it. Let the user change the password though."); } /** * @todo move these rules deeper into the system */ int minPasswordLength = 6; boolean forceNumber = true; boolean forceSpecialChar = false; boolean forceCapitalLetter = false; int maxPasswordLength = 255; /** * * @todo move the business rules for password complexity (once we've * defined them in https://github.com/IQSS/dataverse/issues/694 ) deeper * into the system and have all calls to * DataverseUser.setEncryptedPassword call into the password complexity * validataion method. * * @todo maybe look into why with the combination of minimum 8 * characters, max 255 characters, all other rules disabled that the * password "12345678" is not considered valid. */ PasswordValidator validator = PasswordValidator.buildValidator(forceSpecialChar, forceCapitalLetter, forceNumber, minPasswordLength, maxPasswordLength); boolean passwordIsComplexEnough = validator.validatePassword(newPassword); if (!passwordIsComplexEnough) { messageSummary = messageSummaryFail; messageDetail = "Password is not complex enough. The password must have at least one letter, one number and be at least " + minPasswordLength + " characters in length."; logger.info(messageDetail); return new PasswordChangeAttemptResponse(false, messageSummary, messageDetail); } String newHashedPass = PasswordEncryption.get().encrypt(newPassword); int latestVersionNumber = PasswordEncryption.getLatestVersionNumber(); user.updateEncryptedPassword(newHashedPass, latestVersionNumber); BuiltinUser savedUser = dataverseUserService.save(user); if (savedUser != null) { messageSummary = messageSummarySuccess; messageDetail = messageDetailSuccess; boolean tokenDeleted = deleteToken(token); if (!tokenDeleted) { // suboptimal but when it expires it should be deleted logger.info("token " + token + " for user id " + user.getId() + " was not deleted"); } String toAddress = user.getEmail(); String subject = "Dataverse Password Reset Successfully Changed"; String messageBody = "Hi " + user.getDisplayName() + ",\n\n" + "Your Dataverse account password was successfully changed.\n\n" + "Please contact us if you did not request this password reset or need further help.\n\n"; mailService.sendSystemEmail(toAddress, subject, messageBody); return new PasswordChangeAttemptResponse(true, messageSummary, messageDetail); } else { messageSummary = messageSummaryFail; messageDetail = "Your password was not reset. Please contact support."; logger.info("Enable to save user " + user.getId()); return new PasswordChangeAttemptResponse(false, messageSummary, messageDetail); } } private boolean deleteToken(String token) { PasswordResetData doomed = findSinglePasswordResetDataByToken(token); try { em.remove(doomed); return true; } catch (Exception ex) { logger.info("Caught exception trying to delete token " + token + " - " + ex); return false; } } }