Revision 002f394d
Added by Andreas Kohlbecker over 2 years ago
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
fix #9497 User self registration service implemented