Project

General

Profile

« Previous | Next » 

Revision 93f25a6b

Added by Andreas Kohlbecker over 2 years ago

ref #6161 implementing password recovery service & tests

View differences:

cdmlib-model/src/main/java/eu/etaxonomy/cdm/model/permission/User.java
126 126
    protected String salt;
127 127

  
128 128
    @XmlElement(name = "EmailAddress")
129
    
129 130
    protected String emailAddress;
130 131

  
131 132
    @XmlElementWrapper(name = "GrantedAuthorities")
cdmlib-persistence/src/main/java/eu/etaxonomy/cdm/persistence/dao/hibernate/permission/UserDaoImpl.java
36 36
        query.setParameter("username", username);
37 37

  
38 38
        User user = (User)query.uniqueResult(); // username is a @NaturalId
39
        return initializeUser(user);
40
    }
39 41

  
40
        if(user != null) {
41
            getSession().refresh(user); // make sure the user is always up to date
42
            Hibernate.initialize(user.getPerson());
43
            Hibernate.initialize(user.getGrantedAuthorities());
44
            for(Group group : user.getGroups()) {
45
                Hibernate.initialize(group.getGrantedAuthorities());
46
            }
47
        }
42
    @Override
43
    public User findByEmailAddress(String emailAddress) {
44
        Query query = getSession().createQuery("select user from User user where user.emailAddress = :emailAddress");
45
        query.setParameter("emailAddress", emailAddress);
48 46

  
49
        return user;
47
        User user = (User)query.uniqueResult(); // emailAddress to be unique, see https://dev.e-taxonomy.eu/redmine/issues/7276
48
        return initializeUser(user);
50 49
    }
51 50

  
52 51
    @Override
53
    public long countByUsername(String queryString,	MatchMode matchmode, List<Criterion> criterion) {
52
    public long countByUsername(String queryString, MatchMode matchmode, List<Criterion> criterion) {
54 53
        return countByParam(type, "username",queryString,matchmode,criterion);
55 54
    }
56 55

  
......
59 58
        return findByParam(type, "username", queryString, matchmode, criterion, pageSize, pageNumber, orderHints, propertyPaths);
60 59
    }
61 60

  
61
    public User initializeUser(User user) {
62
        if(user != null) {
63
            getSession().refresh(user); // make sure the user is always up to date
64
            Hibernate.initialize(user.getPerson());
65
            Hibernate.initialize(user.getGrantedAuthorities());
66
            for(Group group : user.getGroups()) {
67
                Hibernate.initialize(group.getGrantedAuthorities());
68
            }
69
        }
70
        return user;
71
    }
72

  
62 73
}
cdmlib-persistence/src/main/java/eu/etaxonomy/cdm/persistence/dao/permission/IUserDao.java
31 31
     */
32 32
    public User findUserByUsername(String username);
33 33

  
34
    /**
35
     * Find the user having the supplied email address
36
     * @param emailAddress
37
     * @return The user or null
38
     */
39
    public User findByEmailAddress(String emailAddress);
40
    
34 41
     /**
35 42
     * Return a List of users matching the given query string, optionally filtered by class, optionally with a particular MatchMode
36 43
     *
cdmlib-services/pom.xml
117 117
      <groupId>com.github.dozermapper</groupId>
118 118
      <artifactId>dozer-core</artifactId>
119 119
    </dependency>
120
    <dependency>
121
        <groupId>com.google.guava</groupId>
122
        <artifactId>guava</artifactId>
123
    </dependency>
124
    <dependency>
125
        <groupId>org.apache.commons</groupId>
126
        <artifactId>commons-text</artifactId>
127
    </dependency>
120 128
  </dependencies>
121 129
</project>
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/config/AppConfiguration.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.config;
10

  
11
import org.springframework.context.annotation.ComponentScan;
12
import org.springframework.context.annotation.ComponentScans;
13
import org.springframework.context.annotation.Configuration;
14
import org.springframework.scheduling.annotation.EnableAsync;
15

  
16
/**
17
 * This is to replace the xml configuration in src/main/resources/eu/etaxonomy/cdm/services.xml
18
 *
19
 * @author a.kohlbecker
20
 * @since Nov 5, 2021
21
 */
22
@Configuration
23
@ComponentScans({
24
    @ComponentScan(basePackages = {
25
            "eu.etaxonomy.cdm.api.security",
26
            "eu.etaxonomy.cdm.api.service.security"
27
            })
28
})
29
@EnableAsync // required for eu.etaxonomy.cdm.api.service.security.PasswordResetService @Async
30
public class AppConfiguration {
31

  
32
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/config/AppConfigurationPropertiesConfig.java
41 41
})
42 42
public class AppConfigurationPropertiesConfig {
43 43

  
44
}
44
}
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 {
20

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

  
23
    public PasswordResetRequest create(User user);
24

  
25
    /**
26
     * Removes the corresponding <code>PasswordResetRequest</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>PasswordResetRequest</code> if it exists
46
     * and is not expired.
47
     *
48
     * @param token
49
     *            The token string
50
     * @return the valid <code>PasswordResetRequest</code> or an empty
51
     *         <code>Optional</code>
52
     */
53
    public Optional<PasswordResetRequest> findResetRequest(String token);
54

  
55

  
56
    public void setTokenLifetimeMinutes(int tokenLifetimeMinutes);
57

  
58
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/security/PasswordResetRequest.java
1

  
2
package eu.etaxonomy.cdm.api.security;
3

  
4
import java.util.Calendar;
5
import java.util.Date;
6

  
7
/**
8
 * @author a.kohlbecker
9
 * @since Oct 26, 2021
10
 */
11
public class PasswordResetRequest {
12

  
13
    private String token;
14

  
15
    private String userName;
16

  
17
    private String userEmail;
18

  
19
    private Date expiryDate;
20

  
21
    /**
22
     * @param user
23
     * @param expireInMinutes
24
     */
25
    protected PasswordResetRequest(String userName, String userEmail, String token, int expireInMinutes) {
26
        super();
27
        this.userName = userName;
28
        this.userEmail = userEmail;
29
        this.token = token;
30
        this.setExpiryDate(expireInMinutes);
31
    }
32

  
33
    public String getToken() {
34
        return token;
35
    }
36

  
37
    public String getUserName() {
38
        return userName;
39
    }
40

  
41
    public Date getExpiryDate() {
42
        return expiryDate;
43
    }
44

  
45
    protected void setExpiryDate(int minutes){
46
        Calendar now = Calendar.getInstance();
47
        now.add(Calendar.MINUTE, minutes);
48
        this.expiryDate = now.getTime();
49
    }
50

  
51
    public boolean isExpired() {
52
        return new Date().after(this.expiryDate);
53
    }
54

  
55
    public String getUserEmail() {
56
        return userEmail;
57
    }
58
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/security/PasswordResetTokenStore.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.security.SecureRandom;
12
import java.util.Base64;
13
import java.util.Base64.Encoder;
14
import java.util.Date;
15
import java.util.HashMap;
16
import java.util.List;
17
import java.util.Map;
18
import java.util.Optional;
19
import java.util.stream.Collectors;
20

  
21
import org.apache.log4j.Logger;
22
import org.springframework.stereotype.Component;
23

  
24
import eu.etaxonomy.cdm.model.permission.User;
25

  
26
/**
27
 * @author a.kohlbecker
28
 * @since Nov 3, 2021
29
 */
30
@Component
31
public class PasswordResetTokenStore implements IPasswordResetTokenStore {
32

  
33
    public  static final int TOKEN_LENGTH = 50;
34

  
35
    private static Logger logger = Logger.getLogger(PasswordResetTokenStore.class);
36

  
37
    private Map<String, PasswordResetRequest> tokenList = new HashMap<>();
38

  
39
    private Integer tokenLifetimeMinutes = null;
40

  
41
    @Override
42
    public PasswordResetRequest create(User user) {
43
        clearExpiredTokens();
44
        assert user != null;
45
        assert !user.getEmailAddress().isEmpty();
46
        PasswordResetRequest token = new PasswordResetRequest(user.getUsername(), user.getEmailAddress(), generateRandomToken(), getTokenLifetimeMinutes());
47
        tokenList.put(token.getToken(), token);
48
        return token;
49
    }
50

  
51
    private String generateRandomToken() {
52
        SecureRandom random = new SecureRandom();
53
        byte bytes[] = new byte[TOKEN_LENGTH];
54
        random.nextBytes(bytes);
55
        Encoder encoder = Base64.getUrlEncoder().withoutPadding();
56
        String token = encoder.encodeToString(bytes);
57
        return token;
58
    }
59

  
60
    @Override
61
    public Optional<PasswordResetRequest> findResetRequest(String token) {
62
        clearExpiredTokens();
63
        PasswordResetRequest resetRequest = tokenList.get(token);
64
        if(isEligibleResetRequest(resetRequest)) {
65
            return Optional.of(resetRequest);
66
        }
67
        return Optional.empty();
68
    }
69

  
70
    @Override
71
    public boolean isEligibleToken(String token) {
72
        clearExpiredTokens();
73
        PasswordResetRequest resetRequest = tokenList.get(token);
74
        return isEligibleResetRequest(resetRequest);
75
    }
76

  
77
    private boolean isEligibleResetRequest(PasswordResetRequest resetRequest) {
78
        if(resetRequest == null) {
79
            logger.error("PasswordResetRequest must not be null");
80
            return false;
81
        }
82
        if(resetRequest.getExpiryDate().before(new Date())) {
83
            tokenList.remove(resetRequest.getToken());
84
            logger.info("Token is expired, and has been deleted now.");
85
            return false;
86
        }
87
        return true;
88
    }
89

  
90
    /**
91
     * To be called periodically to remove expired tokens.
92
     *
93
     * @return the number of expired tokens that have been cleared
94
     */
95
    private int clearExpiredTokens() {
96
        Date now = new Date();
97
        List<String> expiredTokens = tokenList.values().stream().filter(t -> t.getExpiryDate().before(now)).map(t -> t.getToken()).collect(Collectors.toList());
98
        expiredTokens.stream().forEach(tstr -> tokenList.remove(tstr));
99
        return expiredTokens.size();
100
    }
101

  
102
    /**
103
     * Removes the token from the store
104
     *
105
     * @param token
106
     * @return true if the token to be remove has existed, otherwise false
107
     */
108
    @Override
109
    public boolean remove(String token) {
110
        clearExpiredTokens();
111
        return tokenList.remove(token) != null;
112
    }
113

  
114
    public int getTokenLifetimeMinutes() {
115
        return this.tokenLifetimeMinutes != null ? this.tokenLifetimeMinutes : TOKEN_LIFETIME_MINUTES_DEFAULT;
116
    }
117

  
118
    @Override
119
    public void setTokenLifetimeMinutes(int tokenLifetimeMinutes) {
120
        this.tokenLifetimeMinutes = tokenLifetimeMinutes;
121
    }
122

  
123
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/EmailAddressNotFoundException.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 when a user can not be found using the email address.
15
 *
16
 * @author a.kohlbecker
17
 * @since Nov 4, 2021
18
 */
19
public class EmailAddressNotFoundException extends UsernameNotFoundException {
20

  
21
    private static final long serialVersionUID = 1572067792460999503L;
22

  
23
    public EmailAddressNotFoundException(String msg) {
24
        super(msg);
25
    }
26

  
27
}
cdmlib-services/src/main/java/eu/etaxonomy/cdm/api/service/security/IPasswordResetService.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.util.concurrent.ListenableFuture;
12

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

  
19
    /**
20
     * Create a request token and send it to the user via email.
21
     *
22
     * Must conform to the recommendations of <a href=
23
     * "https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html">
24
     * https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html</a>
25
     *
26
     * <ul>
27
     * <li>Hides internal processing time differences by sending the email
28
     * asynchronously</li>
29
     * <li>Access to the method is rate limited, see {@link #RATE_LIMIT}</li>
30
     * </ul>
31
     *
32
     * @param userNameOrEmail
33
     *            The user name or email address of the user requesting for a
34
     *            password reset.
35
     * @param passwordRequestFormUrlTemplate
36
     *            A template string for {@code String.format()} for the URL to
37
     *            the request form in which the user can enter the new password.
38
     *            The template string must contain one string placeholder
39
     *            {@code %s} for the request token string.
40
     * @return A <code>Future</code> for a <code>Boolean</code> flag. The
41
     *         boolean value will be <code>false</code> in case the max access
42
     *         rate for this method has been exceeded and a time out has
43
     *         occurred. Other internal error states are intentionally hidden to
44
     *         avoid leaking of information on the existence of users (see above
45
     *         link to the Forgot_Password_Cheat_Sheet).
46
     */
47
    ListenableFuture<Boolean> emailResetToken(String userNameOrEmail, String passwordRequestFormUrlTemplate);
48

  
49
    /**
50
     *
51
     * @param token
52
     *            the token string
53
     * @param newPassword
54
     *            The new password to set
55
     * @return A <code>Future</code> for a <code>Boolean</code> flag. The
56
     *         boolean value will be <code>false</code> in case the max access
57
     *         rate for this method has been exceeded and a time out has
58
     *         occurred. Other internal error states are intentionally hidden to
59
     *         avoid leaking of information on the existence of users (see above
60
     *         link to the Forgot_Password_Cheat_Sheet).
61
     * @throws PasswordResetException
62
     */
63
    ListenableFuture<Boolean> resetPassword(String token, String newPassword) throws PasswordResetException;
64

  
65
}
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
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.SimpleMailMessage;
26
import org.springframework.mail.javamail.JavaMailSender;
27
import org.springframework.scheduling.annotation.Async;
28
import org.springframework.scheduling.annotation.AsyncResult;
29
import org.springframework.security.core.userdetails.UsernameNotFoundException;
30
import org.springframework.stereotype.Service;
31
import org.springframework.transaction.annotation.Transactional;
32
import org.springframework.util.concurrent.ListenableFuture;
33

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

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

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

  
52
    private static final int RATE_LIMTER_TIMEOUT_SECONDS = 2;
53

  
54
    private static final double PERMITS_PER_SECOND = 0.3;
55

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

  
58
    @Autowired
59
    private IUserDao userDao;
60

  
61
    @Autowired
62
    private IUserService userService;
63

  
64
    @Autowired
65
    private IPasswordResetTokenStore passwordResetTokenStore;
66

  
67
    @Autowired
68
    private JavaMailSender emailSender;
69

  
70
    @Autowired
71
    Environment env;
72

  
73
    RateLimiter emailResetToken_rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
74
    RateLimiter resetPassword_rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
75

  
76
    public static final String RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE = "Your password reset request for ${userName}";
77
    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}"
78
            + " data base. If this was not initiated by you, please ignore this message."
79
            + ".\n Please click ${linkUrl} to reset your password";
80

  
81
    public static final String RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE = "Your password for ${userName} has been changed";
82
    public static final String RESET_SUCCESS_EMAIL_BODY_TEMPLATE = "The password of your account at the ${dataBase} data base has just been changed."
83
            + "If this was not initiated by you, please contact the adminitrator as soon as possible.";
84

  
85
    public static final String RESET_FAILED_EMAIL_SUBJECT_TEMPLATE = "Changing your password for ${userName} has failed";
86
    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."
87
            + "If this was not initiated by you, please contact the adminitrator as soon as possible.";
88

  
89
    /**
90
     * Create a request token and send it to the user via email.
91
     *
92
     * Must conform to the recommendations of <a href=
93
     * "https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html">
94
     * https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html</a>
95
     *
96
     * <ul>
97
     * <li>Hides internal processing time differences by sending the email
98
     * asynchronously</li>
99
     * <li>Access to the method is rate limited, see {@link #RATE_LIMIT}</li>
100
     * </ul>
101
     *
102
     * @param userNameOrEmail
103
     *            The user name or email address of the user requesting for a
104
     *            password reset.
105
     * @param passwordRequestFormUrlTemplate
106
     *            A template string for {@code String.format()} for the URL to
107
     *            the request form in which the user can enter the new password.
108
     *            The template string must contain one string placeholder
109
     *            {@code %s} for the request token string.
110
     * @return A <code>Future</code> for a <code>Boolean</code> flag. The
111
     *         boolean value will be <code>false</code> in case the max access
112
     *         rate for this method has been exceeded and a time out has
113
     *         occurred. Other internal error states are intentionally hidden to
114
     *         avoid leaking of information on the existence of users (see above
115
     *         link to the Forgot_Password_Cheat_Sheet).
116
     */
117
    @Override
118
    @Async
119
    public ListenableFuture<Boolean> emailResetToken(String userNameOrEmail, String passwordRequestFormUrlTemplate) {
120

  
121
        if (emailResetToken_rateLimiter.tryAcquire(Duration.ofSeconds(RATE_LIMTER_TIMEOUT_SECONDS))) {
122

  
123
            try {
124
                User user = findUser(userNameOrEmail);
125
                PasswordResetRequest resetRequest = passwordResetTokenStore.create(user);
126

  
127
                String passwordRequestFormUrl = String.format(passwordRequestFormUrlTemplate, resetRequest.getToken());
128
                Map<String, String> additionalValues = new HashMap<>();
129
                additionalValues.put("linkUrl", passwordRequestFormUrl);
130
                sendEmail(user.getEmailAddress(), user.getUsername(), RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE, RESET_REQUEST_EMAIL_BODY_TEMPLATE,
131
                        additionalValues);
132
                logger.info("Password reset request for  " + userNameOrEmail + " has been send");
133
            } catch (UsernameNotFoundException e) {
134
                logger.warn("Password reset request for unknown user, cause: " + e.getMessage());
135
            }
136
            return new AsyncResult<Boolean>(true);
137
        } else {
138
            return new AsyncResult<Boolean>(false);
139
        }
140
    }
141

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

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

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

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

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

  
179
        emailSender.send(message);
180
    }
181

  
182
    /**
183
     *
184
     * @param userNameOrEmail
185
     * @return
186
     */
187
    protected User findUser(String userNameOrEmail) throws UsernameNotFoundException, EmailAddressNotFoundException {
188

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

  
207
    /**
208
     *
209
     * @param token
210
     *            the token string
211
     * @param newPassword
212
     *            The new password to set
213
     * @return A <code>Future</code> for a <code>Boolean</code> flag. The
214
     *         boolean value will be <code>false</code> in case the max access
215
     *         rate for this method has been exceeded and a time out has
216
     *         occurred. Other internal error states are intentionally hidden to
217
     *         avoid leaking of information on the existence of users (see above
218
     *         link to the Forgot_Password_Cheat_Sheet).
219
     * @throws PasswordResetException
220
     */
221
    @Override
222
    @Async
223
    public ListenableFuture<Boolean> resetPassword(String token, String newPassword) throws PasswordResetException {
224

  
225
        if (resetPassword_rateLimiter.tryAcquire(Duration.ofSeconds(RATE_LIMTER_TIMEOUT_SECONDS))) {
226

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

  
244
}
cdmlib-services/src/test/java/eu/etaxonomy/cdm/api/security/PasswordResetTokenStoreTest.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 static org.junit.Assert.assertEquals;
12
import static org.junit.Assert.assertFalse;
13
import static org.junit.Assert.assertTrue;
14

  
15
import java.io.FileNotFoundException;
16
import java.util.Optional;
17

  
18
import org.junit.Before;
19
import org.junit.Test;
20
import org.unitils.database.annotations.Transactional;
21
import org.unitils.database.util.TransactionMode;
22
import org.unitils.spring.annotation.SpringBeanByType;
23

  
24
import eu.etaxonomy.cdm.model.permission.User;
25
import eu.etaxonomy.cdm.test.integration.CdmTransactionalIntegrationTest;
26

  
27
/**
28
 * @author a.kohlbecker
29
 * @since Nov 5, 2021
30
 */
31
@Transactional(TransactionMode.DISABLED)
32
public class PasswordResetTokenStoreTest extends CdmTransactionalIntegrationTest {
33

  
34
    private static final String USER_EMAIL = "dummy@cybertaxonomy.test";
35
    private static final String USER_PWD = "dummy123";
36
    private static final String USER_NAME = "dummy";
37

  
38
    @SpringBeanByType
39
    private IPasswordResetTokenStore passwordResetTokenStore;
40

  
41
    private User testUser;
42

  
43
    @Before
44
    public void reset() {
45
        passwordResetTokenStore.setTokenLifetimeMinutes(IPasswordResetTokenStore.TOKEN_LIFETIME_MINUTES_DEFAULT);
46
        testUser = User.NewInstance(USER_NAME, USER_PWD);
47
        testUser.setEmailAddress(USER_EMAIL);
48
    }
49

  
50
    @Test
51
    public void testTokenStillValid() {
52
        String token = passwordResetTokenStore.create(testUser).getToken();
53
        assertTrue(passwordResetTokenStore.isEligibleToken(token));
54
        Optional<PasswordResetRequest> resetRequest = passwordResetTokenStore.findResetRequest(token);
55
        assertTrue(resetRequest.isPresent());
56
        assertEquals(USER_NAME, resetRequest.get().getUserName());
57
        assertEquals(USER_EMAIL, resetRequest.get().getUserEmail());
58
        assertEquals(token, resetRequest.get().getToken());
59
    }
60

  
61
    @Test
62
    public void testTokenExpired() {
63
        passwordResetTokenStore.setTokenLifetimeMinutes(-10);
64
        String token = passwordResetTokenStore.create(testUser).getToken();
65
        assertFalse(passwordResetTokenStore.isEligibleToken(token));
66
        Optional<PasswordResetRequest> resetRequest = passwordResetTokenStore.findResetRequest(token);
67
        assertTrue(!resetRequest.isPresent());
68
    }
69

  
70
    @Test
71
    public void testTokenUnknown() {
72
        String unknownToken = "un-known-token";
73
        assertFalse(passwordResetTokenStore.isEligibleToken(unknownToken));
74
        Optional<PasswordResetRequest> resetRequest = passwordResetTokenStore.findResetRequest(unknownToken);
75
        assertTrue(!resetRequest.isPresent());
76
    }
77

  
78
    @Test
79
    public void testTokenNull() {
80
        String nullToken = null;
81
        assertFalse(passwordResetTokenStore.isEligibleToken(nullToken));
82
        Optional<PasswordResetRequest> resetRequest = passwordResetTokenStore.findResetRequest(nullToken);
83
        assertTrue(!resetRequest.isPresent());
84
    }
85

  
86

  
87
    @Test
88
    public void testTokenRemove() {
89
        String token = passwordResetTokenStore.create(testUser).getToken();
90
        assertTrue(passwordResetTokenStore.isEligibleToken(token));
91
        Optional<PasswordResetRequest> resetRequest = passwordResetTokenStore.findResetRequest(token);
92
        assertTrue(resetRequest.isPresent());
93
        passwordResetTokenStore.remove(token);
94
        resetRequest = passwordResetTokenStore.findResetRequest(token);
95
        assertFalse("Expecing false since the token has been removed", resetRequest.isPresent());
96
    }
97

  
98

  
99
    @Override
100
    public void createTestDataSet() throws FileNotFoundException {
101
        // NO DATA NEEDED
102
    }
103

  
104

  
105

  
106
}
cdmlib-services/src/test/java/eu/etaxonomy/cdm/api/service/security/PasswordResetServiceTest.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.util.concurrent.CountDownLatch;
17
import java.util.regex.Matcher;
18
import java.util.regex.Pattern;
19

  
20
import javax.mail.internet.MimeMessage;
21

  
22
import org.junit.After;
23
import org.junit.Before;
24
import org.junit.Test;
25
import org.springframework.mail.javamail.JavaMailSender;
26
import org.springframework.util.concurrent.ListenableFuture;
27
import org.subethamail.wiser.Wiser;
28
import org.subethamail.wiser.WiserMessage;
29
import org.unitils.dbunit.annotation.DataSet;
30
import org.unitils.spring.annotation.SpringBeanByType;
31

  
32
import eu.etaxonomy.cdm.api.security.IPasswordResetTokenStore;
33
import eu.etaxonomy.cdm.api.security.PasswordResetTokenStore;
34
import eu.etaxonomy.cdm.api.service.IUserService;
35
import eu.etaxonomy.cdm.model.permission.User;
36
import eu.etaxonomy.cdm.test.unitils.CleanSweepInsertLoadStrategy;
37

  
38
/**
39
 * @author a.kohlbecker
40
 * @since Nov 8, 2021
41
 */
42
public class PasswordResetServiceTest extends eu.etaxonomy.cdm.test.integration.CdmTransactionalIntegrationTest {
43

  
44
    private static final String userName = "pwdResetTestUser";
45
    private static final String userPWD = "super_SECURE_123";
46
    private static final String newPWD = "NEW_123_new_456";
47
    private static final String userEmail = "pwdResetTestUser@cybertaxonomy.test";
48

  
49

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

  
52

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

  
55
    @SpringBeanByType
56
    private IUserService userService;
57

  
58
    @SpringBeanByType
59
    private IPasswordResetService passwordResetService;
60

  
61
    @SpringBeanByType
62
    private IPasswordResetTokenStore passwordResetTokenStore;
63

  
64
    @SpringBeanByType
65
    private JavaMailSender emailSender;
66

  
67
    private Wiser wiser = null;
68

  
69
    CountDownLatch resetTokenSendSignal;
70
    CountDownLatch passwordChangedSignal;
71
    Throwable assyncError = null;
72

  
73
    @Before
74
    public void startEmailServer() {
75
        wiser = new Wiser();
76
        wiser.setPort(2500); // Default is 25
77
        wiser.start();
78
    }
79

  
80

  
81
    @Before
82
    public void createUser() {
83
        User user = User.NewInstance(userName, userPWD);
84
        user.setEmailAddress(userEmail);
85
        userService.save(user);
86
        commitAndStartNewTransaction();
87
        // printDataSet(System.err, "User");
88

  
89
    }
90

  
91
    @After
92
    public void removeUser() {
93
        userService.deleteUser(userName);
94
        userService.getSession().flush();
95
        commitAndStartNewTransaction();
96
    }
97

  
98
    @After
99
    public void stopEmailServer() {
100
        wiser.stop();
101
    }
102

  
103
    @Test
104
    @DataSet(loadStrategy = CleanSweepInsertLoadStrategy.class, value="/eu/etaxonomy/cdm/database/ClearDBDataSet.xml")
105
    public void testSuccessfulEmailReset() throws Throwable {
106

  
107
        // printDataSet(System.err, "UserAccount");
108

  
109
        resetTokenSendSignal = new CountDownLatch(1);
110
        passwordChangedSignal = new CountDownLatch(1);
111

  
112
        ListenableFuture<Boolean> emailResetFuture = passwordResetService.emailResetToken(userName, requestFormUrlTemplate);
113
        emailResetFuture.addCallback(
114
                requestSuccessVal -> {
115
                    resetTokenSendSignal.countDown();
116
                }, futureException -> {
117
                    assyncError = futureException;
118
                    resetTokenSendSignal.countDown();
119
                });
120

  
121
        // -- wait for passwordResetService.emailResetToken() to complete
122
        resetTokenSendSignal.await();
123

  
124
        if(assyncError != null) {
125
            throw assyncError;
126
        }
127

  
128
        assertNotNull(emailResetFuture.get());
129
        assertEquals(1, wiser.getMessages().size());
130

  
131
        // -- read email message
132
        WiserMessage requestMessage = wiser.getMessages().get(0);
133
        MimeMessage requestMimeMessage = requestMessage.getMimeMessage();
134
        assertEquals(PasswordResetService.RESET_REQUEST_EMAIL_SUBJECT_TEMPLATE.replace("${userName}", userName), requestMimeMessage.getSubject());
135

  
136
        String messageContent = requestMimeMessage.getContent().toString();
137
        // -- extract token
138
        Pattern pattern = Pattern.compile("=\\{(" + base64UrlSaveCharClass + "+)\\}");
139
        Matcher m = pattern.matcher(messageContent);
140
        assertTrue(m.find());
141
        assertEquals(PasswordResetTokenStore.TOKEN_LENGTH + 17, m.group(1).length());
142

  
143
        // -- change password
144
        ListenableFuture<Boolean> resetPasswordFuture = passwordResetService.resetPassword( m.group(1), newPWD);
145
        resetPasswordFuture.addCallback(requestSuccessVal -> {
146
            passwordChangedSignal.countDown();
147
        }, futureException -> {
148
            assyncError =  futureException;
149
            passwordChangedSignal.countDown();
150
        });
151
        // -- wait for passwordResetService.resetPassword to complete
152
        passwordChangedSignal.await();
153

  
154
        assertTrue(resetPasswordFuture.get());
155
        assertEquals(2, wiser.getMessages().size());
156
        WiserMessage successMessage = wiser.getMessages().get(1);
157
        MimeMessage successMimeMessage = successMessage.getMimeMessage();
158
        assertEquals(PasswordResetService.RESET_SUCCESS_EMAIL_SUBJECT_TEMPLATE.replace("${userName}", userName), successMimeMessage.getSubject());
159

  
160
    }
161

  
162

  
163
    @Override
164
    public void createTestDataSet() throws FileNotFoundException {
165
        // not needed
166
    }
167

  
168
}
cdmlib-services/src/test/resources/application.properties
18 18
# this properties allows for disabling the mail agent
19 19
mail.disabled: false 
20 20
# needed to enable wiser in the integration test
21
mail.int-test-server: wiser
21
mail.int-test-server: wiser
22
# ===========================================================
23
# in production environmens this propery is set in cdm-remote
24
# however it is not set in cdm-service and needs to be set 
25
# for the PasswordResetServiceTest
26
#
27
# explitely different name than for the unitils database
28
# to make this property better findable
29
cdm.dataSource.id: cdm-tests-db
pom.xml
1661 1661
         <artifactId>tools</artifactId>
1662 1662
         <version>1.8.0</version>
1663 1663
      </dependency>
1664
      <!-- Email functionality (used in cdmlib-services) -->
1665 1664
      <dependency>
1665
        <!-- Email functionality (used in cdmlib-services) -->
1666 1666
        <groupId>javax.mail</groupId>
1667 1667
        <artifactId>mailapi</artifactId>
1668 1668
        <version>1.4.3</version>
1669 1669
      </dependency>
1670
      <dependency>
1671
        <!-- only needed for PasswordResetService, may be replaced by Thymeleaf -->
1672
        <groupId>org.apache.commons</groupId>
1673
        <artifactId>commons-text</artifactId>
1674
        <version>1.9</version>
1675
    </dependency>
1670 1676
   </dependencies>    
1671 1677
  </dependencyManagement>
1672 1678
</project>

Also available in: Unified diff