Project

General

Profile

« Previous | Next » 

Revision 002f394d

Added by Andreas Kohlbecker over 2 years ago

fix #9497 User self registration service implemented

  • more interfaces and abstract classes
  • AccountRegistrationService implemented
  • test implemented

View differences:

cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/security/AbstractRequestTokenStore.java
26 26
 * @author a.kohlbecker
27 27
 * @since Nov 18, 2021
28 28
 */
29
public abstract class AbstractRequestTokenStore<T extends AbstractRequestToken>  implements IPasswordResetTokenStore<T> {
29
public abstract class AbstractRequestTokenStore<T extends AbstractRequestToken>  implements IAbstractRequestTokenStore<T> {
30 30

  
31 31
    public static final int TOKEN_LENGTH = 50;
32 32
    protected static Logger logger = Logger.getLogger(AbstractRequestTokenStore.class);
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/security/AccountCreationRequestTokenStore.java
27 27
    @Override
28 28
    public AccountCreationRequest createNewToken(User user, String randomToken, int tokenLifetimeMinutes) {
29 29
        userService.encodeUserPassword(user, user.getPassword());
30
        AccountCreationRequest token = new AccountCreationRequest(user.getUsername(), user.getEmailAddress(), user.getPassword(), randomToken, tokenLifetimeMinutes);
30
        AccountCreationRequest token = new AccountCreationRequest(user.getUsername(), user.getPassword(), user.getEmailAddress(), randomToken, tokenLifetimeMinutes);
31 31
        return token;
32 32
    }
33 33

  
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/security/IAbstractRequestTokenStore.java
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.security;
10

  
11
import java.util.Optional;
12

  
13
import eu.etaxonomy.cdm.model.permission.User;
14

  
15
/**
16
 * @author a.kohlbecker
17
 * @since Nov 3, 2021
18
 */
19
public interface IAbstractRequestTokenStore<T extends AbstractRequestToken> {
20

  
21
    public static final int TOKEN_LIFETIME_MINUTES_DEFAULT = 60 * 6;
22

  
23
    public T create(User user);
24

  
25
    /**
26
     * Removes the corresponding <code>AbstractRequestToken</code> from the
27
     * store
28
     *
29
     * @param token
30
     *            The token string
31
     * @return true if the token to be remove has existed, otherwise false
32
     */
33
    public boolean remove(String token);
34

  
35
    /**
36
     * Checks is the supplied token exists and has not expired.
37
     *
38
     * @param token
39
     *            The token string
40
     * @return true if the token is valid
41
     */
42
    public boolean isEligibleToken(String token);
43

  
44
    /**
45
     * Returns the corresponding <code>AbstractRequestToken</code> if it exists
46
     * and is not expired.
47
     *
48
     * @param token
49
     *            The token string
50
     * @return the valid <code>AbstractRequestToken</code> or an empty
51
     *         <code>Optional</code>
52
     */
53
    public Optional<T> findResetRequest(String token);
54

  
55

  
56
    public void setTokenLifetimeMinutes(int tokenLifetimeMinutes);
57

  
58
    public T createNewToken(User user, String randomToken, int tokenLifetimeMinutes);
59

  
60
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/security/IPasswordResetTokenStore.java
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.security;
10

  
11
import java.util.Optional;
12

  
13
import eu.etaxonomy.cdm.model.permission.User;
14

  
15
/**
16
 * @author a.kohlbecker
17
 * @since Nov 3, 2021
18
 */
19
public interface IPasswordResetTokenStore<T extends AbstractRequestToken> {
20

  
21
    public static final int TOKEN_LIFETIME_MINUTES_DEFAULT = 60 * 6;
22

  
23
    public T create(User user);
24

  
25
    /**
26
     * Removes the corresponding <code>AbstractRequestToken</code> from the
27
     * store
28
     *
29
     * @param token
30
     *            The token string
31
     * @return true if the token to be remove has existed, otherwise false
32
     */
33
    public boolean remove(String token);
34

  
35
    /**
36
     * Checks is the supplied token exists and has not expired.
37
     *
38
     * @param token
39
     *            The token string
40
     * @return true if the token is valid
41
     */
42
    public boolean isEligibleToken(String token);
43

  
44
    /**
45
     * Returns the corresponding <code>AbstractRequestToken</code> if it exists
46
     * and is not expired.
47
     *
48
     * @param token
49
     *            The token string
50
     * @return the valid <code>AbstractRequestToken</code> or an empty
51
     *         <code>Optional</code>
52
     */
53
    public Optional<T> findResetRequest(String token);
54

  
55

  
56
    public void setTokenLifetimeMinutes(int tokenLifetimeMinutes);
57

  
58
    public T createNewToken(User user, String randomToken, int tokenLifetimeMinutes);
59

  
60
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/AccountRegistrationService.java
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.util.HashMap;
12
import java.util.Map;
13
import java.util.Optional;
14

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

  
18
import org.apache.log4j.Logger;
19
import org.springframework.beans.factory.annotation.Autowired;
20
import org.springframework.beans.factory.annotation.Qualifier;
21
import org.springframework.dao.DataAccessException;
22
import org.springframework.mail.MailException;
23
import org.springframework.scheduling.annotation.Async;
24
import org.springframework.scheduling.annotation.AsyncResult;
25
import org.springframework.stereotype.Service;
26
import org.springframework.transaction.annotation.Transactional;
27
import org.springframework.util.concurrent.ListenableFuture;
28

  
29
import eu.etaxonomy.cdm.api.security.AbstractRequestToken;
30
import eu.etaxonomy.cdm.api.security.AccountCreationRequest;
31
import eu.etaxonomy.cdm.api.security.IAbstractRequestTokenStore;
32
import eu.etaxonomy.cdm.api.security.PasswordResetRequest;
33
import eu.etaxonomy.cdm.model.permission.User;
34

  
35
/**
36
 * @author a.kohlbecker
37
 * @since Oct 26, 2021
38
 */
39
@Service
40
@Transactional(readOnly = true)
41
public class AccountRegistrationService extends AccountSelfManagementService implements IAccountRegistrationService {
42

  
43
    private static Logger logger = Logger.getLogger(PasswordResetRequest.class);
44

  
45
    @Autowired
46
    @Qualifier("accountCreationRequestTokenStore")
47
    private IAbstractRequestTokenStore<AccountCreationRequest> accountRegistrationTokenStore;
48

  
49
    @Override
50
    @Async
51
    public ListenableFuture<Boolean> emailAccountRegistrationRequest(String emailAddress,
52
            String userName, String password, String accountCreationRequestFormUrlTemplate) throws MailException, AddressException {
53

  
54
        if(logger.isTraceEnabled()) {
55
            logger.trace("emailAccountRegistrationConfirmation() trying to aquire from rate limiter [rate: " + emailResetToken_rateLimiter.getRate() + ", timeout: " + getRateLimiterTimeout().toMillis() + "ms]");
56
        }
57
        if (emailResetToken_rateLimiter.tryAcquire(getRateLimiterTimeout())) {
58
            logger.trace("emailAccountRegistrationConfirmation() allowed by rate limiter");
59
            try {
60
                emailAddressValidAndUnused(emailAddress);
61
                User user = User.NewInstance(userName, password);
62
                user.setEmailAddress(emailAddress);
63
                AbstractRequestToken resetRequest = accountRegistrationTokenStore.create(user);
64
                String passwordRequestFormUrl = String.format(accountCreationRequestFormUrlTemplate, resetRequest.getToken());
65
                Map<String, String> additionalValues = new HashMap<>();
66
                additionalValues.put("linkUrl", passwordRequestFormUrl);
67
                sendEmail(user.getEmailAddress(), user.getUsername(),
68
                        UserAccountEmailTemplates.REGISTRATION_REQUEST_EMAIL_SUBJECT_TEMPLATE,
69
                        UserAccountEmailTemplates.REGISTRATION_REQUEST_EMAIL_BODY_TEMPLATE, additionalValues);
70
                logger.info("An account creartion request has been send to "
71
                        + user.getEmailAddress());
72
                return new AsyncResult<Boolean>(true);
73
            } catch (MailException e) {
74
                throw e;
75
            }
76
        } else {
77
            logger.trace("blocked by rate limiter");
78
            return new AsyncResult<Boolean>(false);
79
        }
80
    }
81

  
82
    @Override
83
    @Async
84
    @Transactional(readOnly = false)
85
    public ListenableFuture<Boolean> createUserAccount(String token, String givenName, String familyName, String prefix)
86
            throws MailException, AccountSelfManagementException, AddressException {
87

  
88
        if (resetPassword_rateLimiter.tryAcquire(getRateLimiterTimeout())) {
89

  
90
            Optional<AccountCreationRequest> creationRequest = accountRegistrationTokenStore.findResetRequest(token);
91
            if (creationRequest.isPresent()) {
92
                try {
93
                    // check again if the email address is still unused
94
                    emailAddressValidAndUnused(creationRequest.get().getUserEmail());
95
                    User newUser = User.NewInstance(creationRequest.get().getUserName(), creationRequest.get().getEncryptedPassword());
96
                    userDao.saveOrUpdate(newUser);
97
                    accountRegistrationTokenStore.remove(token);
98
                    sendEmail(creationRequest.get().getUserEmail(), creationRequest.get().getUserName(),
99
                            UserAccountEmailTemplates.REGISTRATION_SUCCESS_EMAIL_SUBJECT_TEMPLATE,
100
                            UserAccountEmailTemplates.REGISTRATION_SUCCESS_EMAIL_BODY_TEMPLATE, null);
101
                    return new AsyncResult<Boolean>(true);
102
                } catch (DataAccessException e) {
103
                    String message = "Failed to create a new user [userName: " + creationRequest.get().getUserName() + ", email: " + creationRequest.get().getUserEmail() + "]";
104
                    logger.error(message, e);
105
                    throw new AccountSelfManagementException(message);
106
                }
107
            } else {
108
                throw new AccountSelfManagementException("Invalid account creation token");
109
            }
110
        }
111
        return new AsyncResult<Boolean>(false);
112
    }
113

  
114
    /**
115
     * Throws exceptions in case of any problems, returns silently in case
116
     * everything is OK.
117
     *
118
     * @param userNameOrEmail
119
     * @throws AddressException
120
     *             in case the <code>emailAddress</code> is invalid
121
     * @throws EmailAddressAlreadyInUseException
122
     *             in case the <code>emailAddress</code> is in use
123
     */
124
    protected void emailAddressValidAndUnused(String emailAddress)
125
            throws AddressException, EmailAddressAlreadyInUseException {
126
        InternetAddress emailAddr = new InternetAddress(emailAddress);
127
        emailAddr.validate();
128
        if (userDao.findByEmailAddress(emailAddr.toString()) != null) {
129
            throw new EmailAddressAlreadyInUseException("Email address is already in use");
130
        }
131
    }
132

  
133
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/AccountSelfManagementException.java
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
/**
12
 * @author a.kohlbecker
13
 * @since Nov 3, 2021
14
 */
15
public class AccountSelfManagementException extends Exception {
16

  
17
    private static final long serialVersionUID = -2154469325094431262L;
18

  
19
    public AccountSelfManagementException(String message, Throwable cause) {
20
        super(message, cause);
21
    }
22

  
23
    public AccountSelfManagementException(String message) {
24
        super(message);
25
    }
26

  
27
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/AccountSelfManagementService.java
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.Map;
13

  
14
import org.apache.commons.collections4.map.HashedMap;
15
import org.apache.commons.text.StringSubstitutor;
16
import org.apache.log4j.Logger;
17
import org.springframework.beans.factory.annotation.Autowired;
18
import org.springframework.core.env.Environment;
19
import org.springframework.mail.MailException;
20
import org.springframework.mail.SimpleMailMessage;
21
import org.springframework.mail.javamail.JavaMailSender;
22

  
23
import com.google.common.util.concurrent.RateLimiter;
24

  
25
import eu.etaxonomy.cdm.api.config.CdmConfigurationKeys;
26
import eu.etaxonomy.cdm.api.config.SendEmailConfigurer;
27
import eu.etaxonomy.cdm.api.security.PasswordResetRequest;
28
import eu.etaxonomy.cdm.api.service.IUserService;
29
import eu.etaxonomy.cdm.persistence.dao.permission.IUserDao;
30

  
31
/**
32
 * @author a.kohlbecker
33
 * @since Nov 18, 2021
34
 */
35
public abstract class AccountSelfManagementService implements IRateLimitedService {
36

  
37
    protected static Logger logger = Logger.getLogger(PasswordResetRequest.class);
38

  
39
    public static final int RATE_LIMTER_TIMEOUT_SECONDS = 2;
40

  
41
    public static final double PERMITS_PER_SECOND = 0.3;
42

  
43
    @Autowired
44
    protected IUserDao userDao;
45

  
46
    @Autowired
47
    protected IUserService userService;
48

  
49
    @Autowired
50
    protected JavaMailSender emailSender;
51

  
52
    @Autowired
53
    protected Environment env;
54

  
55
    private Duration rateLimiterTimeout = null;
56

  
57
    protected RateLimiter emailResetToken_rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
58

  
59
    protected RateLimiter resetPassword_rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
60

  
61
    /**
62
     * Uses the {@link StringSubstitutor} as simple template engine.
63
     * Below named values are automatically resolved, more can be added via the
64
     * <code>valuesMap</code> parameter.
65
     *
66
     * @param userEmail
67
     *  The TO-address
68
     * @param userName
69
     *  Used to set the value for <code>${userName}</code>
70
     * @param subjectTemplate
71
     *  A {@link StringSubstitutor} template for the email subject
72
     * @param bodyTemplate
73
     *  A {@link StringSubstitutor} template for the email body
74
     * @param additionalValuesMap
75
     *  Additional named values for to be replaced in the template strings.
76
     */
77
    public void sendEmail(String userEmail, String userName, String subjectTemplate, String bodyTemplate, Map<String, String> additionalValuesMap) throws MailException {
78

  
79
        String from = env.getProperty(SendEmailConfigurer.FROM_ADDRESS);
80
        String dataSourceBeanId = env.getProperty(CdmConfigurationKeys.CDM_DATA_SOURCE_ID);
81
        String supportEmailAddress = env.getProperty(CdmConfigurationKeys.MAIL_ADDRESS_SUPPORT);
82
        if(additionalValuesMap == null) {
83
            additionalValuesMap = new HashedMap<>();
84
        }
85
        if(supportEmailAddress != null) {
86
            additionalValuesMap.put("supportEmailAddress", supportEmailAddress);
87
        }
88
        additionalValuesMap.put("userName", userName);
89
        additionalValuesMap.put("dataBase", dataSourceBeanId);
90
        StringSubstitutor substitutor = new StringSubstitutor(additionalValuesMap);
91

  
92
        // TODO use MimeMessages for better email layout?
93
        // TODO user Thymeleaf instead for HTML support?
94
        SimpleMailMessage message = new SimpleMailMessage();
95

  
96
        message.setFrom(from);
97
        message.setTo(userEmail);
98

  
99
        message.setSubject(substitutor.replace(subjectTemplate));
100
        message.setText(substitutor.replace(bodyTemplate));
101

  
102
        emailSender.send(message);
103
    }
104

  
105
    @Override
106
    public Duration getRateLimiterTimeout() {
107
        if(rateLimiterTimeout == null) {
108
            rateLimiterTimeout = Duration.ofSeconds(RATE_LIMTER_TIMEOUT_SECONDS);
109
        }
110
        return rateLimiterTimeout;
111
    }
112

  
113

  
114
    @Override
115
    public void setRateLimiterTimeout(Duration timeout) {
116
        this.rateLimiterTimeout = timeout;
117
    }
118

  
119

  
120
    @Override
121
    public void setRate(double rate) {
122
        resetPassword_rateLimiter.setRate(rate);
123
        emailResetToken_rateLimiter.setRate(rate);
124
    }
125

  
126
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/EmailAddressAlreadyInUseException.java
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 org.springframework.security.core.userdetails.UsernameNotFoundException;
12

  
13
/**
14
 * To be thrown an email address is already bound to an account an can nor be
15
 * used to create a new user account.
16
 *
17
 * @author a.kohlbecker
18
 * @since Nov 4, 2021
19
 */
20
public class EmailAddressAlreadyInUseException extends UsernameNotFoundException {
21

  
22
    private static final long serialVersionUID = 1572067792460999503L;
23

  
24
    public EmailAddressAlreadyInUseException(String msg) {
25
        super(msg);
26
    }
27
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/EmailAddressNotFoundException.java
23 23
    public EmailAddressNotFoundException(String msg) {
24 24
        super(msg);
25 25
    }
26

  
27 26
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/IAccountRegistrationService.java
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 javax.mail.internet.AddressException;
12

  
13
import org.springframework.mail.MailException;
14
import org.springframework.util.concurrent.ListenableFuture;
15

  
16
import eu.etaxonomy.cdm.api.security.AccountCreationRequest;
17

  
18
/**
19
 * @author a.kohlbecker
20
 * @since Nov 18, 2021
21
 */
22
public interface IAccountRegistrationService extends IRateLimitedService {
23

  
24
    public static final int RATE_LIMTER_TIMEOUT_SECONDS = 2;
25

  
26
    public static final double PERMITS_PER_SECOND = 0.3;
27

  
28
    /**
29
     * Create a {@link AccountCreationRequest} token and send it to the user via
30
     * email.
31
     *
32
     * <ul>
33
     * <li>Hides internal processing time differences by sending the email
34
     * asynchronously</li>
35
     * <li>Access to the method is rate limited, see {@link #RATE_LIMIT}</li>
36
     * </ul>
37
     *
38
     * @param emailAddress
39
     *            The email address to send the account creation request to
40
     * @param accountCreationRequestFormUrlTemplate
41
     *            A template string for {@code String.format()} for the URL to
42
     *            the form in which the user can create a new user account. The
43
     *            template string must contain one string placeholder {@code %s}
44
     *            for the request token string.
45
     * @return A <code>Future</code> for a <code>Boolean</code> flag. The
46
     *         boolean value will be <code>false</code> in case the max access
47
     *         rate for this method has been exceeded and a time out has
48
     *         occurred. Internal error states that may expose sensitive
49
     *         information are intentionally hidden this way (see above link to
50
     *         the Forgot_Password_Cheat_Sheet).
51
     * @throws MailException
52
     *             in case sending the email has failed
53
     * @throws AddressException
54
     *             in case the <code>emailAddress</code> in not valid
55
     */
56
    ListenableFuture<Boolean> emailAccountRegistrationRequest(String emailAddress, String userName, String password,
57
            String passwordRequestFormUrlTemplate) throws MailException, AddressException;
58

  
59
    /**
60
     *
61
     * @param token
62
     *            the token string
63
     * @param givenName
64
     *            The new password to set - <b>required</b>
65
     * @param familyName
66
     *            The family name - optional, can be left empty
67
     * @param prefix
68
     *            The family name - optional, can be left empty
69
     * @return A <code>Future</code> for a <code>Boolean</code> flag. The
70
     *         boolean value will be <code>false</code> in case the max access
71
     *         rate for this method has been exceeded and a time out has
72
     *         occurred.
73
     * @throws AccountSelfManagementException
74
     *             in case an invalid token has been used
75
     * @throws MailException
76
     *             in case sending the email has failed
77
     * @throws AddressException
78
     *             in case the <code>emailAddress</code> stored in the
79
     *             {@link AccountCreationRequest} identified by the
80
     *             <code>token</code> not valid
81
     */
82
    ListenableFuture<Boolean> createUserAccount(String token, String givenName, String familyName, String prefix)
83
            throws MailException, AccountSelfManagementException, AddressException;
84

  
85
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/IPasswordResetService.java
8 8
*/
9 9
package eu.etaxonomy.cdm.api.service.security;
10 10

  
11
import java.time.Duration;
12

  
13 11
import org.springframework.mail.MailException;
14 12
import org.springframework.util.concurrent.ListenableFuture;
15 13

  
......
17 15
 * @author a.kohlbecker
18 16
 * @since Nov 8, 2021
19 17
 */
20
public interface IPasswordResetService {
21

  
22
    public static final int RATE_LIMTER_TIMEOUT_SECONDS = 2;
18
public interface IPasswordResetService extends IRateLimitedService {
23 19

  
24
    public static final double PERMITS_PER_SECOND = 0.3;
25 20

  
26 21
    /**
27 22
     * Create a request token and send it to the user via email.
......
65 60
    *         boolean value will be <code>false</code> in case the max access
66 61
    *         rate for this method has been exceeded and a time out has
67 62
    *         occurred.
68
    * @throws PasswordResetException
63
    * @throws AccountSelfManagementException
69 64
    *             in case an invalid token has been used
70 65
    * @throws MailException
71 66
    *             in case sending the email has failed
72 67
    */
73
    ListenableFuture<Boolean> resetPassword(String token, String newPassword) throws PasswordResetException;
74

  
75

  
76
    /**
77
     * Requests to the service methods should be rate limited.
78
     * This method allows to set the timeout when waiting for a
79
     * free execution slot. {@link #RATE_LIMTER_TIMEOUT_SECONDS}
80
     * is the default
81
     */
82
    void setRateLimiterTimeout(Duration timeout);
83

  
68
    ListenableFuture<Boolean> resetPassword(String token, String newPassword) throws AccountSelfManagementException;
84 69

  
85
    /**
86
     * see {@link #setRateLimiterTimeout(Duration)}
87
     *
88
     * @return the currently used timeout
89
     */
90
    Duration getRateLimiterTimeout();
91

  
92
    /**
93
     * Requests to the service methods should be rate limited.
94
     * This method allows to override the default rate
95
     * {@link #PERMITS_PER_SECOND}
96
     */
97
    public void setRate(double rate);
98 70

  
99 71
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/IRateLimitedService.java
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

  
13
/**
14
 * @author a.kohlbecker
15
 * @since Nov 18, 2021
16
 */
17
public interface IRateLimitedService {
18

  
19
    /**
20
     * Requests to the service methods should be rate limited.
21
     * This method allows to set the timeout when waiting for a
22
     * free execution slot. {@link #RATE_LIMTER_TIMEOUT_SECONDS}
23
     * is the default
24
     */
25
    void setRateLimiterTimeout(Duration timeout);
26

  
27

  
28
    /**
29
     * see {@link #setRateLimiterTimeout(Duration)}
30
     *
31
     * @return the currently used timeout
32
     */
33
    Duration getRateLimiterTimeout();
34

  
35
    /**
36
     * Requests to the service methods should be rate limited.
37
     * This method allows to override the default rate
38
     * {@link #PERMITS_PER_SECOND}
39
     */
40
    public void setRate(double rate);
41

  
42
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/PasswordResetException.java
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
/**
12
 * @author a.kohlbecker
13
 * @since Nov 3, 2021
14
 */
15
public class PasswordResetException extends Exception {
16

  
17
    private static final long serialVersionUID = -2154469325094431262L;
18

  
19
    public PasswordResetException(String message, Throwable cause) {
20
        super(message, cause);
21
    }
22

  
23
    public PasswordResetException(String message) {
24
        super(message);
25
    }
26

  
27
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/PasswordResetService.java
8 8
*/
9 9
package eu.etaxonomy.cdm.api.service.security;
10 10

  
11
import java.time.Duration;
12 11
import java.util.HashMap;
13 12
import java.util.Map;
14 13
import java.util.Optional;
......
16 15
import javax.mail.internet.AddressException;
17 16
import javax.mail.internet.InternetAddress;
18 17

  
19
import org.apache.commons.collections4.map.HashedMap;
20
import org.apache.commons.text.StringSubstitutor;
21
import org.apache.log4j.Logger;
22 18
import org.springframework.beans.factory.annotation.Autowired;
23
import org.springframework.core.env.Environment;
19
import org.springframework.beans.factory.annotation.Qualifier;
24 20
import org.springframework.dao.DataAccessException;
25 21
import org.springframework.mail.MailException;
26
import org.springframework.mail.SimpleMailMessage;
27
import org.springframework.mail.javamail.JavaMailSender;
28 22
import org.springframework.scheduling.annotation.Async;
29 23
import org.springframework.scheduling.annotation.AsyncResult;
30 24
import org.springframework.security.core.userdetails.UserDetails;
......
34 28
import org.springframework.util.Assert;
35 29
import org.springframework.util.concurrent.ListenableFuture;
36 30

  
37
import com.google.common.util.concurrent.RateLimiter;
38

  
39
import eu.etaxonomy.cdm.api.config.CdmConfigurationKeys;
40
import eu.etaxonomy.cdm.api.config.SendEmailConfigurer;
41 31
import eu.etaxonomy.cdm.api.security.AbstractRequestToken;
42
import eu.etaxonomy.cdm.api.security.IPasswordResetTokenStore;
32
import eu.etaxonomy.cdm.api.security.IAbstractRequestTokenStore;
43 33
import eu.etaxonomy.cdm.api.security.PasswordResetRequest;
44
import eu.etaxonomy.cdm.api.service.IUserService;
45 34
import eu.etaxonomy.cdm.model.permission.User;
46
import eu.etaxonomy.cdm.persistence.dao.permission.IUserDao;
47 35

  
48 36
/**
49 37
 * @author a.kohlbecker
......
51 39
 */
52 40
@Service
53 41
@Transactional(readOnly = false)
54
public class PasswordResetService implements IPasswordResetService {
55

  
56
    private static Logger logger = Logger.getLogger(PasswordResetRequest.class);
57

  
58
    @Autowired
59
    private IUserDao userDao;
60

  
61
    @Autowired
62
    private IUserService userService;
42
public class PasswordResetService extends AccountSelfManagementService implements IPasswordResetService {
63 43

  
64 44
    @Autowired
65
    private IPasswordResetTokenStore passwordResetTokenStore;
66

  
67
    @Autowired
68
    private JavaMailSender emailSender;
69

  
70
    @Autowired
71
    private Environment env;
72

  
73
    private Duration rateLimiterTimeout = null;
74
    private RateLimiter emailResetToken_rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
75
    private RateLimiter resetPassword_rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
45
    @Qualifier("passwordResetTokenStore")
46
    IAbstractRequestTokenStore<PasswordResetRequest> passwordResetTokenStore;
76 47

  
77 48
    /**
78 49
     * Create a request token and send it to the user via email.
......
120 91
                Map<String, String> additionalValues = new HashMap<>();
121 92
                additionalValues.put("linkUrl", passwordRequestFormUrl);
122 93
                sendEmail(user.getEmailAddress(), user.getUsername(),
123
                        PasswordResetTemplates.RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE,
124
                        PasswordResetTemplates.RESET_REQUEST_EMAIL_BODY_TEMPLATE, additionalValues);
94
                        UserAccountEmailTemplates.RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE,
95
                        UserAccountEmailTemplates.REGISTRATION_REQUEST_EMAIL_BODY_TEMPLATE, additionalValues);
125 96
                logger.info("A password reset request for  " + user.getUsername() + " has been send to "
126 97
                        + user.getEmailAddress());
127 98
            } catch (UsernameNotFoundException e) {
......
137 108
    }
138 109

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

  
157
        String from = env.getProperty(SendEmailConfigurer.FROM_ADDRESS);
158
        String dataSourceBeanId = env.getProperty(CdmConfigurationKeys.CDM_DATA_SOURCE_ID);
159
        String supportEmailAddress = env.getProperty(CdmConfigurationKeys.MAIL_ADDRESS_SUPPORT);
160
        if(additionalValuesMap == null) {
161
            additionalValuesMap = new HashedMap<>();
162
        }
163
        if(supportEmailAddress != null) {
164
            additionalValuesMap.put("supportEmailAddress", supportEmailAddress);
165
        }
166
        additionalValuesMap.put("userName", userName);
167
        additionalValuesMap.put("dataBase", dataSourceBeanId);
168
        StringSubstitutor substitutor = new StringSubstitutor(additionalValuesMap);
169

  
170
        // TODO use MimeMessages for better email layout?
171
        // TODO user Thymeleaf instead for HTML support?
172
        SimpleMailMessage message = new SimpleMailMessage();
173

  
174
        message.setFrom(from);
175
        message.setTo(userEmail);
176

  
177
        message.setSubject(substitutor.replace(subjectTemplate));
178
        message.setText(substitutor.replace(bodyTemplate));
179

  
180
        emailSender.send(message);
181
    }
111
    *
112
    * @param token
113
    *            the token string
114
    * @param newPassword
115
    *            The new password to set
116
    * @return A <code>Future</code> for a <code>Boolean</code> flag. The
117
    *         boolean value will be <code>false</code> in case the max access
118
    *         rate for this method has been exceeded and a time out has
119
    *         occurred.
120
    * @throws AccountSelfManagementException
121
    *             in case an invalid token has been used
122
    * @throws MailException
123
    *             in case sending the email has failed
124
    */
125
   @Override
126
   @Async
127
   public ListenableFuture<Boolean> resetPassword(String token, String newPassword) throws AccountSelfManagementException, MailException {
128

  
129
       if (resetPassword_rateLimiter.tryAcquire(getRateLimiterTimeout())) {
130

  
131
           Optional<PasswordResetRequest> resetRequest = passwordResetTokenStore.findResetRequest(token);
132
           if (resetRequest.isPresent()) {
133
               try {
134
                   UserDetails user = userService.loadUserByUsername(resetRequest.get().getUserName());
135
                   Assert.isAssignable(user.getClass(), User.class);
136
                   userService.encodeUserPassword((User)user, newPassword);
137
                   userDao.saveOrUpdate((User)user);
138
                   passwordResetTokenStore.remove(token);
139
                   sendEmail(resetRequest.get().getUserEmail(), resetRequest.get().getUserName(),
140
                           UserAccountEmailTemplates.RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE,
141
                           UserAccountEmailTemplates.RESET_SUCCESS_EMAIL_BODY_TEMPLATE, null);
142
                   return new AsyncResult<Boolean>(true);
143
               } catch (DataAccessException | IllegalArgumentException | UsernameNotFoundException e) {
144
                   logger.error("Failed to change password of User " + resetRequest.get().getUserName(), e);
145
                   sendEmail(resetRequest.get().getUserEmail(), resetRequest.get().getUserName(),
146
                           UserAccountEmailTemplates.RESET_FAILED_EMAIL_SUBJECT_TEMPLATE,
147
                           UserAccountEmailTemplates.RESET_FAILED_EMAIL_BODY_TEMPLATE, null);
148
               }
149
           } else {
150
               throw new AccountSelfManagementException("Invalid password reset token");
151
           }
152
       }
153
       return new AsyncResult<Boolean>(false);
154
   }
182 155

  
183 156
    /**
184 157
     *
......
204 177
        }
205 178
        return user;
206 179
    }
207

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

  
227
        if (resetPassword_rateLimiter.tryAcquire(getRateLimiterTimeout())) {
228

  
229
            Optional<PasswordResetRequest> resetRequest = passwordResetTokenStore.findResetRequest(token);
230
            if (resetRequest.isPresent()) {
231
                try {
232
                    UserDetails user = userService.loadUserByUsername(resetRequest.get().getUserName());
233
                    Assert.isAssignable(user.getClass(), User.class);
234
                    userService.encodeUserPassword((User)user, newPassword);
235
                    userDao.saveOrUpdate((User)user);
236
                    passwordResetTokenStore.remove(token);
237
                    sendEmail(resetRequest.get().getUserEmail(), resetRequest.get().getUserName(),
238
                            PasswordResetTemplates.RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE,
239
                            PasswordResetTemplates.RESET_SUCCESS_EMAIL_BODY_TEMPLATE, null);
240
                    return new AsyncResult<Boolean>(true);
241
                } catch (DataAccessException | IllegalArgumentException | UsernameNotFoundException e) {
242
                    logger.error("Failed to change password of User " + resetRequest.get().getUserName(), e);
243
                    sendEmail(resetRequest.get().getUserEmail(), resetRequest.get().getUserName(),
244
                            PasswordResetTemplates.RESET_FAILED_EMAIL_SUBJECT_TEMPLATE,
245
                            PasswordResetTemplates.RESET_FAILED_EMAIL_BODY_TEMPLATE, null);
246
                }
247
            } else {
248
                throw new PasswordResetException("Invalid password reset token");
249
            }
250
        }
251
        return new AsyncResult<Boolean>(false);
252
    }
253

  
254
    @Override
255
    public Duration getRateLimiterTimeout() {
256
        if(rateLimiterTimeout == null) {
257
            rateLimiterTimeout = Duration.ofSeconds(RATE_LIMTER_TIMEOUT_SECONDS);
258
        }
259
        return rateLimiterTimeout;
260
    }
261

  
262
    @Override
263
    public void setRateLimiterTimeout(Duration timeout) {
264
        this.rateLimiterTimeout = timeout;
265
    }
266

  
267
    @Override
268
    public void setRate(double rate) {
269
        resetPassword_rateLimiter.setRate(rate);
270
        emailResetToken_rateLimiter.setRate(rate);
271
    }
272 180
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/PasswordResetTemplates.java
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
/**
12
 * @author a.kohlbecker
13
 * @since Nov 11, 2021
14
 */
15
public class PasswordResetTemplates {
16

  
17
    public static final String RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE = "Your password reset request for ${userName}";
18
    public static final String RESET_REQUEST_EMAIL_BODY_TEMPLATE = "You are receiving this email because a password reset was requested for your account at the ${dataBase}"
19
            + " data base. If this was not initiated by you, please ignore this message."
20
            + "\n\nPlease click ${linkUrl} to reset your password";
21

  
22
    public static final String RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE = "Your password for ${userName} has been changed";
23
    public static final String RESET_SUCCESS_EMAIL_BODY_TEMPLATE = "The password of your account (${userName}) at the ${dataBase} data base has just been changed."
24
            + "\n\nIf this was not initiated by you, please contact the administrator (${supportEmailAddress}) as soon as possible.";
25

  
26
    public static final String RESET_FAILED_EMAIL_SUBJECT_TEMPLATE = "Changing your password for ${userName} has failed";
27
    public static final String RESET_FAILED_EMAIL_BODY_TEMPLATE = "The attempt to change the password of your account at the ${dataBase} data base has failed."
28
            + "\n\nIf this was not initiated by you, please contact the administrator (${supportEmailAddress}) as soon as possible.";
29

  
30
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/UserAccountEmailTemplates.java
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
/**
12
 * @author a.kohlbecker
13
 * @since Nov 11, 2021
14
 */
15
public class UserAccountEmailTemplates {
16

  
17
    public static final String RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE = "Your password reset request for ${userName}";
18
    public static final String RESET_REQUEST_EMAIL_BODY_TEMPLATE = "You are receiving this email because a password reset was requested for your account at the ${dataBase}"
19
            + " data base. If this was not initiated by you, please ignore this message."
20
            + "\n\nPlease click ${linkUrl} to reset your password";
21

  
22
    public static final String RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE = "Your password for ${userName} has been changed";
23
    public static final String RESET_SUCCESS_EMAIL_BODY_TEMPLATE = "The password of your account (${userName}) at the ${dataBase} data base has just been changed."
24
            + "\n\nIf this was not initiated by you, please contact the administrator (${supportEmailAddress}) as soon as possible.";
25

  
26
    public static final String RESET_FAILED_EMAIL_SUBJECT_TEMPLATE = "Changing your password for ${userName} has failed";
27
    public static final String RESET_FAILED_EMAIL_BODY_TEMPLATE = "The attempt to change the password of your account at the ${dataBase} data base has failed."
28
            + "\n\nIf this was not initiated by you, please contact the administrator (${supportEmailAddress}) as soon as possible.";
29

  
30
    public static final String REGISTRATION_REQUEST_EMAIL_SUBJECT_TEMPLATE = "Your requested for new user account at ${dataBase}";
31
    public static final String REGISTRATION_REQUEST_EMAIL_BODY_TEMPLATE = "You are receiving this email because you requested for a new account at the ${dataBase}"
32
            + " data base. If this was not initiated by you, please ignore this message."
33
            + "\n\nPlease click ${linkUrl} to start creating your user account.";
34

  
35
    public static final String REGISTRATION_SUCCESS_EMAIL_SUBJECT_TEMPLATE = "The new user account (${userName}) has been changed";
36
    public static final String REGISTRATION_SUCCESS_EMAIL_BODY_TEMPLATE = "Your account (${userName}) at the ${dataBase} data base has just been created."
37
            + "\n\nIf this was not initiated by you, please contact the administrator (${supportEmailAddress}).";
38

  
39
}
cdmlib-services/src/test/java/eu/etaxonomy/cdm/api/security/PasswordResetTokenStoreTest.java
17 17

  
18 18
import org.junit.Before;
19 19
import org.junit.Test;
20
import org.springframework.beans.factory.annotation.Qualifier;
20 21
import org.unitils.database.annotations.Transactional;
21 22
import org.unitils.database.util.TransactionMode;
22 23
import org.unitils.spring.annotation.SpringBeanByType;
......
36 37
    private static final String USER_NAME = "dummy";
37 38

  
38 39
    @SpringBeanByType
39
    private IPasswordResetTokenStore passwordResetTokenStore;
40
    @Qualifier("passwordResetTokenStore")
41
    private IAbstractRequestTokenStore passwordResetTokenStore;
40 42

  
41 43
    private User testUser;
42 44

  
43 45
    @Before
44 46
    public void reset() {
45
        passwordResetTokenStore.setTokenLifetimeMinutes(IPasswordResetTokenStore.TOKEN_LIFETIME_MINUTES_DEFAULT);
47
        passwordResetTokenStore.setTokenLifetimeMinutes(IAbstractRequestTokenStore.TOKEN_LIFETIME_MINUTES_DEFAULT);
46 48
        testUser = User.NewInstance(USER_NAME, USER_PWD);
47 49
        testUser.setEmailAddress(USER_EMAIL);
48 50
    }
cdmlib-services/src/test/java/eu/etaxonomy/cdm/api/service/security/AccountRegistrationServiceTest.java
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 static org.junit.Assert.assertEquals;
12
import static org.junit.Assert.assertNotNull;
13
import static org.junit.Assert.assertTrue;
14

  
15
import java.io.FileNotFoundException;
16
import java.time.Duration;
17
import java.util.concurrent.CountDownLatch;
18
import java.util.regex.Matcher;
19
import java.util.regex.Pattern;
20

  
21
import javax.mail.internet.AddressException;
22
import javax.mail.internet.MimeMessage;
23

  
24
import org.apache.log4j.Level;
25
import org.apache.log4j.Logger;
26
import org.junit.After;
27
import org.junit.Before;
28
import org.junit.Test;
29
import org.springframework.beans.factory.annotation.Autowired;
30
import org.springframework.core.env.Environment;
31
import org.springframework.mail.javamail.JavaMailSender;
32
import org.springframework.util.concurrent.ListenableFuture;
33
import org.subethamail.wiser.Wiser;
34
import org.subethamail.wiser.WiserMessage;
35
import org.unitils.dbunit.annotation.DataSet;
36
import org.unitils.spring.annotation.SpringBeanByName;
37
import org.unitils.spring.annotation.SpringBeanByType;
38

  
39
import eu.etaxonomy.cdm.api.security.AbstractRequestTokenStore;
40
import eu.etaxonomy.cdm.api.security.AccountCreationRequest;
41
import eu.etaxonomy.cdm.api.security.IAbstractRequestTokenStore;
42
import eu.etaxonomy.cdm.api.security.PasswordResetRequest;
43
import eu.etaxonomy.cdm.api.service.IUserService;
44
import eu.etaxonomy.cdm.test.unitils.CleanSweepInsertLoadStrategy;
45

  
46

  
47
public class AccountRegistrationServiceTest extends eu.etaxonomy.cdm.test.integration.CdmTransactionalIntegrationTest {
48

  
49
    private static final double maxRequestRate = 4.0;
50

  
51
    Logger logger = Logger.getLogger(AccountRegistrationServiceTest.class);
52

  
53
    private static final int rateLimiterTimeout = 200;
54
    private static final String userName = "pwdResetTestUser";
55
    private static final String userPWD = "super_SECURE_123";
56
    private static final String userEmail = "pwdResetTestUser@cybertaxonomy.test";
57

  
58

  
59
    private static String base64UrlSaveCharClass = "[a-zA-Z0-9\\-_]";
60

  
61

  
62
    private static final String requestFormUrlTemplate = "http://cybertaxonomy.test/passwordReset?userName={%s}&sessID=f8d8sf8dsf";
63

  
64
    @SpringBeanByType
65
    private IUserService userService;
66

  
67
    @SpringBeanByType
68
    private IAccountRegistrationService accountRegistrationService;
69

  
70
    @SpringBeanByName
71
    private IAbstractRequestTokenStore<AccountCreationRequest> accountCreationRequestTokenStore;
72

  
73
    @SpringBeanByType
74
    private JavaMailSender emailSender;
75

  
76
    @Autowired
77
    private Environment env;
78

  
79
    private Wiser wiser = null;
80

  
81
    private CountDownLatch createRequestTokenSendSignal;
82
    private CountDownLatch accountCreatedSignal;
83
    Throwable assyncError = null;
84

  
85
    @Before
86
    public void startEmailServer() {
87
        // Integer smtpPort = env.getProperty(SendEmailConfigurer.PORT, Integer.class);
88
        wiser = new Wiser();
89
        wiser.setPort(2500); // must be the same as configured for SendEmailConfigurer.PORT
90
        wiser.start();
91
        logger.debug("Wiser email server started");
92
    }
93

  
94

  
95
    @Before
96
    public void accountRegistrationService() throws InterruptedException {
97
        logger.setLevel(Level.DEBUG);
98
        Logger.getLogger(PasswordResetRequest.class).setLevel(Level.TRACE);
99
        // speed up testing
100
        accountRegistrationService.setRateLimiterTimeout(Duration.ofMillis(rateLimiterTimeout));
101
        accountRegistrationService.setRate(maxRequestRate);
102
        // pause long enough to avoid conflicts
103
        long sleepTime = Math.round(1000 / maxRequestRate) + rateLimiterTimeout;
104
        Thread.sleep(sleepTime);
105
    }
106

  
107
    @Before
108
    public void resetAsyncVars() {
109
        assyncError = null;
110
        createRequestTokenSendSignal = null;
111
        accountCreatedSignal = null;
112
    }
113

  
114
    @After
115
    public void stopEmailServer() {
116
        wiser.stop();
117
    }
118

  
119
    @Test
120
    @DataSet(loadStrategy = CleanSweepInsertLoadStrategy.class, value="/eu/etaxonomy/cdm/database/ClearDBDataSet.xml")
121
    public void testSuccessfulEmailReset() throws Throwable {
122

  
123
        logger.debug("testSuccessfulEmailReset() ...");
124

  
125
        // printDataSet(System.err, "UserAccount");
126

  
127
        createRequestTokenSendSignal = new CountDownLatch(1);
128
        accountCreatedSignal = new CountDownLatch(1);
129

  
130
        ListenableFuture<Boolean> emailResetFuture = accountRegistrationService.emailAccountRegistrationRequest(userEmail, userName, userPWD, requestFormUrlTemplate);
131
        emailResetFuture.addCallback(
132
                requestSuccessVal -> {
133
                    createRequestTokenSendSignal.countDown();
134
                }, futureException -> {
135
                    assyncError = futureException;
136
                    createRequestTokenSendSignal.countDown();
137
                });
138

  
139
        // -- wait for passwordResetService.emailResetToken() to complete
140
        createRequestTokenSendSignal.await();
141

  
142
        if(assyncError != null) {
143
            throw assyncError;
144
        }
145

  
146
        assertNotNull(emailResetFuture.get());
147
        assertEquals(1, wiser.getMessages().size());
148

  
149
        // -- read email message
150
        WiserMessage requestMessage = wiser.getMessages().get(0);
151
        MimeMessage requestMimeMessage = requestMessage.getMimeMessage();
152

  
153
        assertTrue(requestMimeMessage.getSubject()
154
                .matches(UserAccountEmailTemplates.REGISTRATION_REQUEST_EMAIL_SUBJECT_TEMPLATE.replace("${dataBase}", ".*"))
155
                );
156

  
157
        String messageContent = requestMimeMessage.getContent().toString();
158
        // -- extract token
159
        Pattern pattern = Pattern.compile("=\\{(" + base64UrlSaveCharClass + "+)\\}");
160
        Matcher m = pattern.matcher(messageContent);
161
        assertTrue(m.find());
162
        assertEquals(AbstractRequestTokenStore.TOKEN_LENGTH + 17, m.group(1).length());
163

  
164
        // -- change password
165
        ListenableFuture<Boolean> createAccountFuture = accountRegistrationService.createUserAccount(m.group(1), "Testor", "Nutzer", "Dr.");
166
        createAccountFuture.addCallback(requestSuccessVal -> {
167
            accountCreatedSignal.countDown();
168
        }, futureException -> {
169
            assyncError =  futureException;
170
            accountCreatedSignal.countDown();
171
        });
172
        // -- wait for passwordResetService.resetPassword to complete
173
        accountCreatedSignal.await();
174

  
175
        assertTrue(createAccountFuture.get());
176
        assertEquals(2, wiser.getMessages().size());
177
        WiserMessage successMessage = wiser.getMessages().get(1);
178
        MimeMessage successMimeMessage = successMessage.getMimeMessage();
179
        assertEquals(UserAccountEmailTemplates.REGISTRATION_SUCCESS_EMAIL_SUBJECT_TEMPLATE.replace("${userName}", userName), successMimeMessage.getSubject());
180
    }
181

  
182
    @Test
183
    @DataSet(loadStrategy = CleanSweepInsertLoadStrategy.class, value="/eu/etaxonomy/cdm/database/ClearDBDataSet.xml")
184
    public void emailResetToken_ivalidEmailAddress() throws Throwable {
185

  
186
        logger.debug("emailResetToken_ivalidEmailAddress() ...");
187

  
188
        createRequestTokenSendSignal = new CountDownLatch(1);
189

  
190
        accountRegistrationService.setRateLimiterTimeout(Duration.ofMillis(1)); // as should as possible to allow the fist call to be successful (with 1ns the fist call fails!)
191
        ListenableFuture<Boolean> emailResetFuture = accountRegistrationService.emailAccountRegistrationRequest("not-a-valid-email@#address#", userName, userPWD, requestFormUrlTemplate);
192
        emailResetFuture.addCallback(
193
                requestSuccessVal -> {
194
                    createRequestTokenSendSignal.countDown();
195
                }, futureException -> {
196
                    assyncError = futureException;
197
                    createRequestTokenSendSignal.countDown();
198
                });
199

  
200

  
201
        // -- wait for passwordResetService.emailResetToken() to complete
202
        createRequestTokenSendSignal.await();
203

  
204
        assertNotNull(assyncError);
205
        assertEquals(AddressException.class, assyncError.getClass());
206
    }
207

  
208
    @Test
209
    @DataSet(loadStrategy = CleanSweepInsertLoadStrategy.class, value="/eu/etaxonomy/cdm/database/ClearDBDataSet.xml")
210
    public void testInvalidToken() throws Throwable {
211

  
212
        logger.debug("testInvalidToken() ...");
213

  
214
        accountCreatedSignal = new CountDownLatch(1);
215

  
216
        // -- change password
217
        ListenableFuture<Boolean> resetPasswordFuture = accountRegistrationService.createUserAccount("IUER9843URIO--INVALID-TOKEN--UWEUR89EUWWEOIR", userName, null, null);
218
        resetPasswordFuture.addCallback(requestSuccessVal -> {
219
            accountCreatedSignal.countDown();
220
        }, futureException -> {
221
            assyncError =  futureException;
222
            accountCreatedSignal.countDown();
223
        });
224
        // -- wait for passwordResetService.resetPassword to complete
225
        accountCreatedSignal.await();
226

  
227
        assertNotNull(assyncError);
228
        assertEquals(AccountSelfManagementException.class, assyncError.getClass());
229
        assertEquals(0, wiser.getMessages().size());
230
    }
231

  
232
    @Override
233
    public void createTestDataSet() throws FileNotFoundException {
234
        // not needed
235
    }
236

  
237
}
cdmlib-services/src/test/java/eu/etaxonomy/cdm/api/service/security/PasswordResetServiceTest.java
33 33
import org.subethamail.wiser.Wiser;
34 34
import org.subethamail.wiser.WiserMessage;
35 35
import org.unitils.dbunit.annotation.DataSet;
36
import org.unitils.spring.annotation.SpringBeanByName;
36 37
import org.unitils.spring.annotation.SpringBeanByType;
37 38

  
38 39
import eu.etaxonomy.cdm.api.security.AbstractRequestTokenStore;
39
import eu.etaxonomy.cdm.api.security.IPasswordResetTokenStore;
40
import eu.etaxonomy.cdm.api.security.IAbstractRequestTokenStore;
40 41
import eu.etaxonomy.cdm.api.security.PasswordResetRequest;
41 42
import eu.etaxonomy.cdm.api.service.IUserService;
42 43
import eu.etaxonomy.cdm.model.permission.User;
......
48 49
 */
49 50
public class PasswordResetServiceTest extends eu.etaxonomy.cdm.test.integration.CdmTransactionalIntegrationTest {
50 51

  
51
    /**
52
     *
53
     */
54 52
    private static final double maxRequestRate = 4.0;
55 53

  
56 54
    Logger logger = Logger.getLogger(PasswordResetServiceTest.class);
......
73 71
    @SpringBeanByType
74 72
    private IPasswordResetService passwordResetService;
75 73

  
76
    @SpringBeanByType
77
    private IPasswordResetTokenStore passwordResetTokenStore;
74
    @SpringBeanByName
75
    private IAbstractRequestTokenStore<PasswordResetRequest> passwordResetTokenStore;
78 76

  
79 77
    @SpringBeanByType
80 78
    private JavaMailSender emailSender;
......
173 171
        // -- read email message
174 172
        WiserMessage requestMessage = wiser.getMessages().get(0);
175 173
        MimeMessage requestMimeMessage = requestMessage.getMimeMessage();
176
        assertEquals(PasswordResetTemplates.RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE.replace("${userName}", userName), requestMimeMessage.getSubject());
174
        assertEquals(UserAccountEmailTemplates.RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE.replace("${userName}", userName), requestMimeMessage.getSubject());
177 175

  
178 176
        String messageContent = requestMimeMessage.getContent().toString();
179 177
        // -- extract token
......
197 195
        assertEquals(2, wiser.getMessages().size());
198 196
        WiserMessage successMessage = wiser.getMessages().get(1);
199 197
        MimeMessage successMimeMessage = successMessage.getMimeMessage();
200
        assertEquals(PasswordResetTemplates.RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE.replace("${userName}", userName), successMimeMessage.getSubject());
198
        assertEquals(UserAccountEmailTemplates.RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE.replace("${userName}", userName), successMimeMessage.getSubject());
201 199
    }
202 200

  
203 201
    @Test
......
297 295
        passwordChangedSignal.await();
298 296

  
299 297
        assertNotNull(assyncError);
300
        assertEquals(PasswordResetException.class, assyncError.getClass());
298
        assertEquals(AccountSelfManagementException.class, assyncError.getClass());
301 299
        assertEquals(0, wiser.getMessages().size());
302 300
    }
303 301

  

Also available in: Unified diff