Revision 93f25a6b
Added by Andreas Kohlbecker over 2 years ago
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
ref #6161 implementing password recovery service & tests