Project

General

Profile

Download (10.8 KB) Statistics
| Branch: | Tag: | Revision:
1
/**
2
* Copyright (C) 2021 EDIT
3
* European Distributed Institute of Taxonomy
4
* http://www.e-taxonomy.eu
5
*
6
* The contents of this file are subject to the Mozilla Public License Version 1.1
7
* See LICENSE.TXT at the top of this package for the full license terms.
8
*/
9
package eu.etaxonomy.cdm.api.service.security;
10

    
11
import java.time.Duration;
12
import java.util.HashMap;
13
import java.util.Map;
14
import java.util.Optional;
15

    
16
import javax.mail.internet.AddressException;
17
import javax.mail.internet.InternetAddress;
18

    
19
import org.apache.commons.collections4.map.HashedMap;
20
import org.apache.commons.text.StringSubstitutor;
21
import org.apache.log4j.Logger;
22
import org.springframework.beans.factory.annotation.Autowired;
23
import org.springframework.core.env.Environment;
24
import org.springframework.dao.DataAccessException;
25
import org.springframework.mail.MailException;
26
import org.springframework.mail.SimpleMailMessage;
27
import org.springframework.mail.javamail.JavaMailSender;
28
import org.springframework.scheduling.annotation.Async;
29
import org.springframework.scheduling.annotation.AsyncResult;
30
import org.springframework.security.core.userdetails.UsernameNotFoundException;
31
import org.springframework.stereotype.Service;
32
import org.springframework.transaction.annotation.Transactional;
33
import org.springframework.util.concurrent.ListenableFuture;
34

    
35
import com.google.common.util.concurrent.RateLimiter;
36

    
37
import eu.etaxonomy.cdm.api.config.CdmConfigurationKeys;
38
import eu.etaxonomy.cdm.api.config.SendEmailConfigurer;
39
import eu.etaxonomy.cdm.api.security.IPasswordResetTokenStore;
40
import eu.etaxonomy.cdm.api.security.PasswordResetRequest;
41
import eu.etaxonomy.cdm.api.service.IUserService;
42
import eu.etaxonomy.cdm.model.permission.User;
43
import eu.etaxonomy.cdm.persistence.dao.permission.IUserDao;
44

    
45
/**
46
 * @author a.kohlbecker
47
 * @since Oct 26, 2021
48
 */
49
@Service
50
@Transactional(readOnly = true)
51
public class PasswordResetService implements IPasswordResetService {
52

    
53
    private static Logger logger = Logger.getLogger(PasswordResetRequest.class);
54

    
55
    @Autowired
56
    private IUserDao userDao;
57

    
58
    @Autowired
59
    private IUserService userService;
60

    
61
    @Autowired
62
    private IPasswordResetTokenStore passwordResetTokenStore;
63

    
64
    @Autowired
65
    private JavaMailSender emailSender;
66

    
67
    @Autowired
68
    private Environment env;
69

    
70
    private Duration rateLimiterTimeout = null;
71
    private RateLimiter emailResetToken_rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
72
    private RateLimiter resetPassword_rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
73

    
74
    /**
75
     * Create a request token and send it to the user via email.
76
     *
77
     * Must conform to the recommendations of <a href=
78
     * "https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html">
79
     * https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html</a>
80
     *
81
     * <ul>
82
     * <li>Hides internal processing time differences by sending the email
83
     * asynchronously</li>
84
     * <li>Access to the method is rate limited, see {@link #RATE_LIMIT}</li>
85
     * </ul>
86
     *
87
     * @param userNameOrEmail
88
     *            The user name or email address of the user requesting for a
89
     *            password reset.
90
     * @param passwordRequestFormUrlTemplate
91
     *            A template string for {@code String.format()} for the URL to
92
     *            the request form in which the user can enter the new password.
93
     *            The template string must contain one string placeholder
94
     *            {@code %s} for the request token string.
95
     * @return A <code>Future</code> for a <code>Boolean</code> flag. The
96
     *         boolean value will be <code>false</code> in case the max access
97
     *         rate for this method has been exceeded and a time out has
98
     *         occurred. Internal error states that may
99
     *         expose sensitive information are intentionally hidden this way
100
     *         (see above link to the Forgot_Password_Cheat_Sheet).
101
     * @throws MailException
102
     *             in case sending the email has failed
103
     */
104
    @Override
105
    @Async
106
    public ListenableFuture<Boolean> emailResetToken(String userNameOrEmail, String passwordRequestFormUrlTemplate) throws MailException {
107

    
108
        if(logger.isTraceEnabled()) {
109
            logger.trace("emailResetToken trying to aquire from rate limiter [rate: " + emailResetToken_rateLimiter.getRate() + ", timeout: " + getRateLimiterTimeout().toMillis() + "ms]");
110
        }
111
        if (emailResetToken_rateLimiter.tryAcquire(getRateLimiterTimeout())) {
112
            logger.trace("emailResetToken allowed by rate limiter");
113
            try {
114
                User user = findUser(userNameOrEmail);
115
                PasswordResetRequest resetRequest = passwordResetTokenStore.create(user);
116
                String passwordRequestFormUrl = String.format(passwordRequestFormUrlTemplate, resetRequest.getToken());
117
                Map<String, String> additionalValues = new HashMap<>();
118
                additionalValues.put("linkUrl", passwordRequestFormUrl);
119
                sendEmail(user.getEmailAddress(), user.getUsername(),
120
                        PasswordResetTemplates.RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE,
121
                        PasswordResetTemplates.RESET_REQUEST_EMAIL_BODY_TEMPLATE, additionalValues);
122
                logger.info("A password reset request for  " + user.getUsername() + " has been send to "
123
                        + user.getEmailAddress());
124
            } catch (UsernameNotFoundException e) {
125
                logger.warn("Password reset request for unknown user, cause: " + e.getMessage());
126
            } catch (MailException e) {
127
                throw e;
128
            }
129
            return new AsyncResult<Boolean>(true);
130
        } else {
131
            logger.trace("blocked by rate limiter");
132
            return new AsyncResult<Boolean>(false);
133
        }
134
    }
135

    
136
    /**
137
     * Uses the {@link StringSubstitutor} as simple template engine.
138
     * Below named values are automatically resoved, more can be added via the
139
     * <code>valuesMap</code> parameter.
140
     *
141
     * @param userEmail
142
     *  The TO-address
143
     * @param userName
144
     *  Used to set the value for <code>${userName}</code>
145
     * @param subjectTemplate
146
     *  A {@link StringSubstitutor} template for the email subject
147
     * @param bodyTemplate
148
     *  A {@link StringSubstitutor} template for the email body
149
     * @param additionalValuesMap
150
     *  Additional named values for to be replaced in the template strings.
151
     */
152
    public void sendEmail(String userEmail, String userName, String subjectTemplate, String bodyTemplate, Map<String, String> additionalValuesMap) throws MailException {
153

    
154
        String from = env.getProperty(SendEmailConfigurer.FROM_ADDRESS);
155
        String dataSourceBeanId = env.getProperty(CdmConfigurationKeys.CDM_DATA_SOURCE_ID);
156
        if(additionalValuesMap == null) {
157
            additionalValuesMap = new HashedMap<>();
158
        }
159
        additionalValuesMap.put("userName", userName);
160
        additionalValuesMap.put("dataBase", dataSourceBeanId);
161
        StringSubstitutor substitutor = new StringSubstitutor(additionalValuesMap);
162

    
163
        // TODO use MimeMessages for better email layout?
164
        // TODO user Thymeleaf instead for HTML support?
165
        SimpleMailMessage message = new SimpleMailMessage();
166

    
167
        message.setFrom(from);
168
        message.setTo(userEmail);
169

    
170
        message.setSubject(substitutor.replace(subjectTemplate));
171
        message.setText(substitutor.replace(bodyTemplate));
172

    
173
        emailSender.send(message);
174
    }
175

    
176
    /**
177
     *
178
     * @param userNameOrEmail
179
     * @return
180
     */
181
    protected User findUser(String userNameOrEmail) throws UsernameNotFoundException, EmailAddressNotFoundException {
182

    
183
        User user;
184
        try {
185
            InternetAddress emailAddr = new InternetAddress(userNameOrEmail);
186
            emailAddr.validate();
187
            user = userDao.findByEmailAddress(userNameOrEmail);
188
            if (user == null) {
189
                throw new EmailAddressNotFoundException(
190
                        "No user with the email address'" + userNameOrEmail + "' found.");
191
            }
192
        } catch (AddressException ex) {
193
            user = userDao.findUserByUsername(userNameOrEmail);
194
            if (user == null) {
195
                throw new UsernameNotFoundException("No user with the user name: '" + userNameOrEmail + "' found.");
196
            }
197
        }
198
        return user;
199
    }
200

    
201
    /**
202
     *
203
     * @param token
204
     *            the token string
205
     * @param newPassword
206
     *            The new password to set
207
     * @return A <code>Future</code> for a <code>Boolean</code> flag. The
208
     *         boolean value will be <code>false</code> in case the max access
209
     *         rate for this method has been exceeded and a time out has
210
     *         occurred.
211
     * @throws PasswordResetException
212
     *             in case an invalid token has been used
213
     * @throws MailException
214
     *             in case sending the email has failed
215
     */
216
    @Override
217
    @Async
218
    public ListenableFuture<Boolean> resetPassword(String token, String newPassword) throws PasswordResetException, MailException {
219

    
220
        if (resetPassword_rateLimiter.tryAcquire(getRateLimiterTimeout())) {
221

    
222
            Optional<PasswordResetRequest> resetRequest = passwordResetTokenStore.findResetRequest(token);
223
            if (resetRequest.isPresent()) {
224
                try {
225
                    userService.changePasswordForUser(resetRequest.get().getUserName(), newPassword);
226
                    sendEmail(resetRequest.get().getUserEmail(), resetRequest.get().getUserName(),
227
                            PasswordResetTemplates.RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE,
228
                            PasswordResetTemplates.RESET_SUCCESS_EMAIL_BODY_TEMPLATE, null);
229
                    return new AsyncResult<Boolean>(true);
230
                } catch (DataAccessException | UsernameNotFoundException e) {
231
                    logger.error("Failed to change password of User " + resetRequest.get().getUserName(), e);
232
                    sendEmail(resetRequest.get().getUserEmail(), resetRequest.get().getUserName(),
233
                            PasswordResetTemplates.RESET_FAILED_EMAIL_SUBJECT_TEMPLATE,
234
                            PasswordResetTemplates.RESET_FAILED_EMAIL_BODY_TEMPLATE, null);
235
                }
236
            } else {
237
                throw new PasswordResetException("Invalid password reset token");
238
            }
239
        }
240
        return new AsyncResult<Boolean>(false);
241
    }
242

    
243
    @Override
244
    public Duration getRateLimiterTimeout() {
245
        if(rateLimiterTimeout == null) {
246
            rateLimiterTimeout = Duration.ofSeconds(RATE_LIMTER_TIMEOUT_SECONDS);
247
        }
248
        return rateLimiterTimeout;
249
    }
250

    
251
    @Override
252
    public void setRateLimiterTimeout(Duration timeout) {
253
        this.rateLimiterTimeout = timeout;
254
    }
255

    
256
    @Override
257
    public void setRate(double rate) {
258
        resetPassword_rateLimiter.setRate(rate);
259
        emailResetToken_rateLimiter.setRate(rate);
260
    }
261
}
(4-4/5)