[Improvement]- Improve the password reset feature (#6545)
* -limit the rate for sending password reset requests * -used encrypted token in password reset * -add unit tests for the password reset issue * -improved formatting * -updated PR as per review comments * -hanled IllegalStateException instead of Exception when parsing the encrypted token
This commit is contained in:
parent
a80c92694b
commit
919a420aa7
|
|
@ -57,12 +57,12 @@ const validate = (values: ResetPasswordFormValues) => {
|
|||
type ResetPasswordProps = InjectedFormProps<
|
||||
ResetPasswordFormValues,
|
||||
{
|
||||
verifyToken: (token: string, email: string) => void;
|
||||
verifyToken: (token: string) => void;
|
||||
isTokenValid: boolean;
|
||||
validatingToken: boolean;
|
||||
}
|
||||
> & {
|
||||
verifyToken: (token: string, email: string) => void;
|
||||
verifyToken: (token: string) => void;
|
||||
isTokenValid: boolean;
|
||||
validatingToken: boolean;
|
||||
theme: Theme;
|
||||
|
|
@ -83,11 +83,10 @@ export function ResetPassword(props: ResetPasswordProps) {
|
|||
} = props;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (initialValues.token && initialValues.email)
|
||||
verifyToken(initialValues.token, initialValues.email);
|
||||
}, [initialValues.token, initialValues.email, verifyToken]);
|
||||
if (initialValues.token) verifyToken(initialValues.token);
|
||||
}, [initialValues.token, verifyToken]);
|
||||
|
||||
const showInvalidMessage = !initialValues.token || !initialValues.email;
|
||||
const showInvalidMessage = !initialValues.token;
|
||||
const showExpiredMessage = !isTokenValid && !validatingToken;
|
||||
const showSuccessMessage = submitSucceeded && !pristine;
|
||||
const showFailureMessage = submitFailed && !!error;
|
||||
|
|
@ -209,7 +208,6 @@ export default connect(
|
|||
const queryParams = new URLSearchParams(props.location.search);
|
||||
return {
|
||||
initialValues: {
|
||||
email: queryParams.get("email") || undefined,
|
||||
token: queryParams.get("token") || undefined,
|
||||
},
|
||||
isTokenValid: getIsTokenValid(state),
|
||||
|
|
@ -217,17 +215,17 @@ export default connect(
|
|||
};
|
||||
},
|
||||
(dispatch: any) => ({
|
||||
verifyToken: (token: string, email: string) =>
|
||||
verifyToken: (token: string) =>
|
||||
dispatch({
|
||||
type: ReduxActionTypes.RESET_PASSWORD_VERIFY_TOKEN_INIT,
|
||||
payload: { token, email },
|
||||
payload: { token },
|
||||
}),
|
||||
}),
|
||||
)(
|
||||
reduxForm<
|
||||
ResetPasswordFormValues,
|
||||
{
|
||||
verifyToken: (token: string, email: string) => void;
|
||||
verifyToken: (token: string) => void;
|
||||
validatingToken: boolean;
|
||||
isTokenValid: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,10 +318,14 @@ export function* verifyResetPasswordTokenSaga(
|
|||
request,
|
||||
);
|
||||
const isValidResponse = yield validateResponse(response);
|
||||
if (isValidResponse) {
|
||||
if (isValidResponse && response.data) {
|
||||
yield put({
|
||||
type: ReduxActionTypes.RESET_PASSWORD_VERIFY_TOKEN_SUCCESS,
|
||||
});
|
||||
} else {
|
||||
yield put({
|
||||
type: ReduxActionErrorTypes.RESET_PASSWORD_VERIFY_TOKEN_ERROR,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ public class UserController extends BaseController<UserService, User, String> {
|
|||
}
|
||||
|
||||
@GetMapping("/verifyPasswordResetToken")
|
||||
public Mono<ResponseDTO<Boolean>> verifyPasswordResetToken(@RequestParam String email, @RequestParam String token) {
|
||||
return service.verifyPasswordResetToken(email, token)
|
||||
public Mono<ResponseDTO<Boolean>> verifyPasswordResetToken(@RequestParam String token) {
|
||||
return service.verifyPasswordResetToken(token)
|
||||
.map(result -> new ResponseDTO<>(HttpStatus.OK.value(), result, null));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,16 @@ import lombok.Setter;
|
|||
import lombok.ToString;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@NoArgsConstructor
|
||||
@Document
|
||||
public class PasswordResetToken extends BaseDomain {
|
||||
|
||||
String tokenHash;
|
||||
|
||||
//Password Reset Token should be valid only for a specified amount of time.
|
||||
String email;
|
||||
int requestCount; // number of requests in last 24 hours
|
||||
Instant firstRequestTime; // when a request was first generated in last 24 hours
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.appsmith.server.dtos;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class EmailTokenDTO {
|
||||
private String email;
|
||||
private String token;
|
||||
}
|
||||
|
|
@ -94,6 +94,7 @@ public enum AppsmithError {
|
|||
INVALID_CURL_HEADER(400, 4036, "Invalid header in cURL command: {0}.", AppsmithErrorAction.DEFAULT, null),
|
||||
AUTHENTICATION_FAILURE(500, 5010, "Authentication failed with error: {0}", AppsmithErrorAction.DEFAULT, null),
|
||||
INSTANCE_REGISTRATION_FAILURE(500, 5011, "Registration for instance failed with error: {0}", AppsmithErrorAction.LOG_EXTERNALLY, null),
|
||||
TOO_MANY_REQUESTS(429, 4039, "Too many requests received. Please try later.", AppsmithErrorAction.DEFAULT, "Too many requests"),
|
||||
;
|
||||
|
||||
private final Integer httpErrorCode;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public interface UserService extends CrudService<User, String> {
|
|||
|
||||
Mono<Boolean> forgotPasswordTokenGenerate(ResetUserPasswordDTO resetUserPasswordDTO);
|
||||
|
||||
Mono<Boolean> verifyPasswordResetToken(String email, String token);
|
||||
Mono<Boolean> verifyPasswordResetToken(String token);
|
||||
|
||||
Mono<Boolean> resetPasswordAfterForgotPassword(String token, User user);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.appsmith.server.services;
|
|||
|
||||
import com.appsmith.external.helpers.BeanCopyUtils;
|
||||
import com.appsmith.external.models.Policy;
|
||||
import com.appsmith.external.services.EncryptionService;
|
||||
import com.appsmith.server.acl.AclPermission;
|
||||
import com.appsmith.server.acl.AppsmithRole;
|
||||
import com.appsmith.server.acl.RoleGraph;
|
||||
|
|
@ -17,6 +18,7 @@ import com.appsmith.server.domains.PasswordResetToken;
|
|||
import com.appsmith.server.domains.QUser;
|
||||
import com.appsmith.server.domains.User;
|
||||
import com.appsmith.server.domains.UserRole;
|
||||
import com.appsmith.server.dtos.EmailTokenDTO;
|
||||
import com.appsmith.server.dtos.InviteUsersDTO;
|
||||
import com.appsmith.server.dtos.ResetUserPasswordDTO;
|
||||
import com.appsmith.server.dtos.UserProfileDTO;
|
||||
|
|
@ -31,6 +33,9 @@ import com.appsmith.server.repositories.PasswordResetTokenRepository;
|
|||
import com.appsmith.server.repositories.UserRepository;
|
||||
import com.appsmith.server.solutions.UserChangedHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.client.utils.URLEncodedUtils;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
|
||||
import org.springframework.data.mongodb.core.convert.MongoConverter;
|
||||
|
|
@ -48,6 +53,8 @@ import reactor.core.scheduler.Scheduler;
|
|||
import javax.validation.Validator;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
|
|
@ -83,10 +90,11 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
|
|||
private final CommonConfig commonConfig;
|
||||
private final EmailConfig emailConfig;
|
||||
private final UserChangedHandler userChangedHandler;
|
||||
private final EncryptionService encryptionService;
|
||||
|
||||
private static final String WELCOME_USER_EMAIL_TEMPLATE = "email/welcomeUserTemplate.html";
|
||||
private static final String FORGOT_PASSWORD_EMAIL_TEMPLATE = "email/forgotPasswordTemplate.html";
|
||||
private static final String FORGOT_PASSWORD_CLIENT_URL_FORMAT = "%s/user/resetPassword?token=%s&email=%s";
|
||||
private static final String FORGOT_PASSWORD_CLIENT_URL_FORMAT = "%s/user/resetPassword?token=%s";
|
||||
private static final String INVITE_USER_CLIENT_URL_FORMAT = "%s/user/signup?email=%s";
|
||||
private static final String INVITE_USER_EMAIL_TEMPLATE = "email/inviteUserCreatorTemplate.html";
|
||||
private static final String USER_ADDED_TO_ORGANIZATION_EMAIL_TEMPLATE = "email/inviteExistingUserToOrganizationTemplate.html";
|
||||
|
|
@ -111,7 +119,8 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
|
|||
ConfigService configService,
|
||||
CommonConfig commonConfig,
|
||||
EmailConfig emailConfig,
|
||||
UserChangedHandler userChangedHandler) {
|
||||
UserChangedHandler userChangedHandler,
|
||||
EncryptionService encryptionService) {
|
||||
super(scheduler, validator, mongoConverter, reactiveMongoTemplate, repository, analyticsService);
|
||||
this.organizationService = organizationService;
|
||||
this.sessionUserService = sessionUserService;
|
||||
|
|
@ -127,6 +136,7 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
|
|||
this.commonConfig = commonConfig;
|
||||
this.emailConfig = emailConfig;
|
||||
this.userChangedHandler = userChangedHandler;
|
||||
this.encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -196,7 +206,7 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
|
|||
String email = resetUserPasswordDTO.getEmail();
|
||||
|
||||
// Create a random token to be sent out.
|
||||
String token = UUID.randomUUID().toString();
|
||||
final String token = UUID.randomUUID().toString();
|
||||
log.debug("Password reset Token: {} for email: {}", token, email);
|
||||
|
||||
// Check if the user exists in our DB. If not, we will not send a password reset link to the user
|
||||
|
|
@ -208,22 +218,29 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
|
|||
.switchIfEmpty(Mono.defer(() -> {
|
||||
PasswordResetToken passwordResetToken = new PasswordResetToken();
|
||||
passwordResetToken.setEmail(email);
|
||||
passwordResetToken.setRequestCount(1);
|
||||
passwordResetToken.setFirstRequestTime(Instant.now());
|
||||
return Mono.just(passwordResetToken);
|
||||
}))
|
||||
.map(resetToken -> {
|
||||
// check the validity of the token
|
||||
validateResetLimit(resetToken);
|
||||
resetToken.setTokenHash(passwordEncoder.encode(token));
|
||||
return resetToken;
|
||||
});
|
||||
|
||||
// Save the password reset link and send an email to the user
|
||||
// Save the password reset link and send email to the user
|
||||
Mono<Boolean> resetFlowMono = passwordResetTokenMono
|
||||
.flatMap(passwordResetTokenRepository::save)
|
||||
.flatMap(obj -> {
|
||||
List<NameValuePair> nameValuePairs = new ArrayList<>(2);
|
||||
nameValuePairs.add(new BasicNameValuePair("email", email));
|
||||
nameValuePairs.add(new BasicNameValuePair("token", token));
|
||||
String urlParams = URLEncodedUtils.format(nameValuePairs, StandardCharsets.UTF_8);
|
||||
String resetUrl = String.format(
|
||||
FORGOT_PASSWORD_CLIENT_URL_FORMAT,
|
||||
resetUserPasswordDTO.getBaseUrl(),
|
||||
URLEncoder.encode(token, StandardCharsets.UTF_8),
|
||||
URLEncoder.encode(email, StandardCharsets.UTF_8)
|
||||
encryptionService.encryptString(urlParams)
|
||||
);
|
||||
|
||||
Map<String, String> params = Map.of("resetUrl", resetUrl);
|
||||
|
|
@ -234,88 +251,114 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
|
|||
params
|
||||
);
|
||||
})
|
||||
.thenReturn(true)
|
||||
.onErrorResume(error -> {
|
||||
log.error("Unable to send email because the template replacement failed. Cause: ", error);
|
||||
return Mono.just(true);
|
||||
});
|
||||
.thenReturn(true);
|
||||
|
||||
// Connect the components to first find a valid user and then initiate the password reset flow
|
||||
return userMono.then(resetFlowMono);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method checks whether the reset request limit has been exceeded.
|
||||
* If the limit has been exceeded, it raises an Exception.
|
||||
* Otherwise, it'll update the counter and date in the resetToken object
|
||||
* @param resetToken {@link PasswordResetToken}
|
||||
*/
|
||||
private void validateResetLimit(PasswordResetToken resetToken) {
|
||||
if(resetToken.getRequestCount() >= 3) {
|
||||
Duration duration = Duration.between(resetToken.getFirstRequestTime(), Instant.now());
|
||||
long l = duration.toHours();
|
||||
if(l >= 24) { // ok, reset the counter
|
||||
resetToken.setRequestCount(1);
|
||||
resetToken.setFirstRequestTime(Instant.now());
|
||||
} else { // too many requests, raise an exception
|
||||
throw new AppsmithException(AppsmithError.TOO_MANY_REQUESTS);
|
||||
}
|
||||
} else {
|
||||
resetToken.setRequestCount(resetToken.getRequestCount() + 1);
|
||||
if(resetToken.getFirstRequestTime() == null) {
|
||||
resetToken.setFirstRequestTime(Instant.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function verifies if the password reset token and email match each other. Should be initiated after the
|
||||
* user has already initiated a password reset request via the 'Forgot Password' link. The tokens are stored in the
|
||||
* DB using BCrypt hash.
|
||||
*
|
||||
* @param email The email of the user whose password is being reset
|
||||
* @param token The one-time token provided to the user for resetting the password
|
||||
* @param encryptedToken The one-time token provided to the user for resetting the password
|
||||
* @return Publishes a boolean indicating whether the given token is valid for the given email address
|
||||
*/
|
||||
@Override
|
||||
public Mono<Boolean> verifyPasswordResetToken(String email, String token) {
|
||||
public Mono<Boolean> verifyPasswordResetToken(String encryptedToken) {
|
||||
EmailTokenDTO emailTokenDTO;
|
||||
try {
|
||||
emailTokenDTO = parseValueFromEncryptedToken(encryptedToken);
|
||||
} catch (IllegalStateException e) {
|
||||
return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.TOKEN));
|
||||
}
|
||||
|
||||
return passwordResetTokenRepository
|
||||
.findByEmail(email)
|
||||
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.EMAIL, email)))
|
||||
.flatMap(obj -> {
|
||||
boolean matches = this.passwordEncoder.matches(token, obj.getTokenHash());
|
||||
if (!matches) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
|
||||
return repository
|
||||
.findByEmail(email)
|
||||
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.USER, email)))
|
||||
.map(user -> {
|
||||
user.setPasswordResetInitiated(true);
|
||||
return user;
|
||||
})
|
||||
.flatMap(repository::save)
|
||||
// Everything went fine till now. Cheerio!
|
||||
.thenReturn(true);
|
||||
});
|
||||
.findByEmail(emailTokenDTO.getEmail())
|
||||
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.INVALID_PASSWORD_RESET)))
|
||||
.map(obj -> this.passwordEncoder.matches(emailTokenDTO.getToken(), obj.getTokenHash()));
|
||||
}
|
||||
|
||||
/**
|
||||
* This function resets the password using the one-time token & email of the user.
|
||||
* This function can only be called via the forgot password route.
|
||||
*
|
||||
* @param token The one-time token provided to the user for resetting the password
|
||||
* @param encryptedToken The one-time token provided to the user for resetting the password
|
||||
* @param user The user object that contains the email & password fields in order to save the new password for the user
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Mono<Boolean> resetPasswordAfterForgotPassword(String token, User user) {
|
||||
public Mono<Boolean> resetPasswordAfterForgotPassword(String encryptedToken, User user) {
|
||||
EmailTokenDTO emailTokenDTO;
|
||||
try {
|
||||
emailTokenDTO = parseValueFromEncryptedToken(encryptedToken);
|
||||
} catch (IllegalStateException e) {
|
||||
return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.TOKEN));
|
||||
}
|
||||
|
||||
return repository
|
||||
.findByEmail(user.getEmail())
|
||||
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.USER, user.getEmail())))
|
||||
.flatMap(userFromDb -> {
|
||||
if (!userFromDb.getPasswordResetInitiated()) {
|
||||
return Mono.error(new AppsmithException(AppsmithError.INVALID_PASSWORD_RESET));
|
||||
} else if(!ValidationUtils.validateLoginPassword(user.getPassword())){
|
||||
return Mono.error(new AppsmithException(
|
||||
AppsmithError.INVALID_PASSWORD_LENGTH, LOGIN_PASSWORD_MIN_LENGTH, LOGIN_PASSWORD_MAX_LENGTH)
|
||||
);
|
||||
return passwordResetTokenRepository
|
||||
.findByEmail(emailTokenDTO.getEmail())
|
||||
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.INVALID_PASSWORD_RESET)))
|
||||
.map(passwordResetToken -> {
|
||||
boolean matches = this.passwordEncoder.matches(emailTokenDTO.getToken(), passwordResetToken.getTokenHash());
|
||||
if (!matches) {
|
||||
throw new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, FieldName.TOKEN);
|
||||
} else {
|
||||
return emailTokenDTO.getEmail();
|
||||
}
|
||||
})
|
||||
.flatMap(emailAddress -> repository
|
||||
.findByEmail(emailAddress)
|
||||
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.USER, emailAddress)))
|
||||
.flatMap(userFromDb -> {
|
||||
if(!ValidationUtils.validateLoginPassword(user.getPassword())){
|
||||
return Mono.error(new AppsmithException(
|
||||
AppsmithError.INVALID_PASSWORD_LENGTH, LOGIN_PASSWORD_MIN_LENGTH, LOGIN_PASSWORD_MAX_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
//User has verified via the forgot password token verfication route. Allow the user to set new password.
|
||||
userFromDb.setPasswordResetInitiated(false);
|
||||
userFromDb.setPassword(passwordEncoder.encode(user.getPassword()));
|
||||
//User has verified via the forgot password token verfication route. Allow the user to set new password.
|
||||
userFromDb.setPasswordResetInitiated(false);
|
||||
userFromDb.setPassword(passwordEncoder.encode(user.getPassword()));
|
||||
|
||||
// If the user has been invited but has not signed up yet, and is following the route of reset
|
||||
// password flow to set up their password, enable the user's account as well
|
||||
userFromDb.setIsEnabled(true);
|
||||
// If the user has been invited but has not signed up yet, and is following the route of reset
|
||||
// password flow to set up their password, enable the user's account as well
|
||||
userFromDb.setIsEnabled(true);
|
||||
|
||||
return passwordResetTokenRepository
|
||||
.findByEmail(user.getEmail())
|
||||
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.TOKEN, token)))
|
||||
.flatMap(passwordResetTokenRepository::delete)
|
||||
.then(repository.save(userFromDb))
|
||||
.thenReturn(true);
|
||||
});
|
||||
return passwordResetTokenRepository
|
||||
.findByEmail(userFromDb.getEmail())
|
||||
.switchIfEmpty(Mono.error(new AppsmithException(
|
||||
AppsmithError.NO_RESOURCE_FOUND, FieldName.TOKEN, emailTokenDTO.getToken()
|
||||
)))
|
||||
.flatMap(passwordResetTokenRepository::delete)
|
||||
.then(repository.save(userFromDb))
|
||||
.thenReturn(true);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -813,4 +856,14 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
|
|||
});
|
||||
}
|
||||
|
||||
private EmailTokenDTO parseValueFromEncryptedToken(String encryptedToken) {
|
||||
String decryptString = encryptionService.decryptString(encryptedToken);
|
||||
List<NameValuePair> nameValuePairs = URLEncodedUtils.parse(decryptString, StandardCharsets.UTF_8);
|
||||
Map<String, String> params = new HashMap<>();
|
||||
|
||||
for(NameValuePair nameValuePair : nameValuePairs) {
|
||||
params.put(nameValuePair.getName(), nameValuePair.getValue());
|
||||
}
|
||||
return new EmailTokenDTO(params.get("email"), params.get("token"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.appsmith.server.services;
|
||||
|
||||
import com.appsmith.external.models.Policy;
|
||||
import com.appsmith.external.services.EncryptionService;
|
||||
import com.appsmith.server.acl.AppsmithRole;
|
||||
import com.appsmith.server.configurations.WithMockAppsmithUser;
|
||||
import com.appsmith.server.constants.FieldName;
|
||||
|
|
@ -8,19 +9,26 @@ import com.appsmith.server.domains.Application;
|
|||
import com.appsmith.server.domains.InviteUser;
|
||||
import com.appsmith.server.domains.LoginSource;
|
||||
import com.appsmith.server.domains.Organization;
|
||||
import com.appsmith.server.domains.PasswordResetToken;
|
||||
import com.appsmith.server.domains.User;
|
||||
import com.appsmith.server.dtos.InviteUsersDTO;
|
||||
import com.appsmith.server.dtos.ResetUserPasswordDTO;
|
||||
import com.appsmith.server.exceptions.AppsmithError;
|
||||
import com.appsmith.server.exceptions.AppsmithException;
|
||||
import com.appsmith.server.repositories.PasswordResetTokenRepository;
|
||||
import com.appsmith.server.repositories.UserRepository;
|
||||
import com.appsmith.server.solutions.UserSignup;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.client.utils.URLEncodedUtils;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.test.context.support.WithUserDetails;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
|
|
@ -31,6 +39,8 @@ import reactor.core.publisher.Flux;
|
|||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
|
|
@ -69,6 +79,12 @@ public class UserServiceTest {
|
|||
@Autowired
|
||||
PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired
|
||||
EncryptionService encryptionService;
|
||||
|
||||
@MockBean
|
||||
PasswordResetTokenRepository passwordResetTokenRepository;
|
||||
|
||||
Mono<User> userMono;
|
||||
|
||||
Mono<Organization> organizationMono;
|
||||
|
|
@ -470,4 +486,102 @@ public class UserServiceTest {
|
|||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forgotPasswordTokenGenerate_AfterTrying3TimesIn24Hours_ThrowsException() {
|
||||
String testEmail = "test-email-for-password-reset";
|
||||
PasswordResetToken passwordResetToken = new PasswordResetToken();
|
||||
passwordResetToken.setRequestCount(3);
|
||||
passwordResetToken.setFirstRequestTime(Instant.now());
|
||||
|
||||
// mock the passwordResetTokenRepository to return request count 3 in 24 hours
|
||||
Mockito.when(passwordResetTokenRepository.findByEmail(testEmail)).thenReturn(Mono.just(passwordResetToken));
|
||||
|
||||
ResetUserPasswordDTO resetUserPasswordDTO = new ResetUserPasswordDTO();
|
||||
resetUserPasswordDTO.setEmail("test-email-for-password-reset");
|
||||
|
||||
StepVerifier.create(userService.forgotPasswordTokenGenerate(resetUserPasswordDTO))
|
||||
.expectError(AppsmithException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyPasswordResetToken_WhenMalformedToken_ThrowsException() {
|
||||
String encryptedToken = "abcdef"; // malformed token
|
||||
StepVerifier.create(userService.verifyPasswordResetToken(encryptedToken))
|
||||
.verifyError(AppsmithException.class);
|
||||
}
|
||||
|
||||
private String getEncodedToken(String emailAddress, String token) {
|
||||
List<NameValuePair> nameValuePairs = new ArrayList<>(2);
|
||||
nameValuePairs.add(new BasicNameValuePair("email", emailAddress));
|
||||
nameValuePairs.add(new BasicNameValuePair("token", token));
|
||||
String urlParams = URLEncodedUtils.format(nameValuePairs, StandardCharsets.UTF_8);
|
||||
return encryptionService.encryptString(urlParams);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyPasswordResetToken_WhenTokenDoesNotExist_ThrowsException() {
|
||||
String testEmail = "abc@example.org";
|
||||
// mock the passwordResetTokenRepository to return empty
|
||||
Mockito.when(passwordResetTokenRepository.findByEmail(testEmail)).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(userService.verifyPasswordResetToken(getEncodedToken(testEmail, "123456789")))
|
||||
.expectErrorMessage(AppsmithError.INVALID_PASSWORD_RESET.getMessage())
|
||||
.verify();
|
||||
}
|
||||
|
||||
private void testResetPasswordTokenMatch(String token1, String token2, boolean expectedResult) {
|
||||
String testEmail = "abc@example.org";
|
||||
PasswordResetToken passwordResetToken = new PasswordResetToken();
|
||||
passwordResetToken.setTokenHash(passwordEncoder.encode(token1));
|
||||
|
||||
// mock the passwordResetTokenRepository to return empty
|
||||
Mockito.when(passwordResetTokenRepository.findByEmail(testEmail)).thenReturn(Mono.just(passwordResetToken));
|
||||
|
||||
StepVerifier.create(userService.verifyPasswordResetToken(getEncodedToken(testEmail, token2)))
|
||||
.expectNext(expectedResult)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyPasswordResetToken_WhenTokenDoesNotMatch_ReturnsFalse() {
|
||||
testResetPasswordTokenMatch("0123456789", "123456789", false); // different tokens
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyPasswordResetToken_WhenTokenMatches_ReturnsTrue() {
|
||||
testResetPasswordTokenMatch("0123456789", "0123456789", true); // same token
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetPasswordAfterForgotPassword_WhenMalformedToken_ThrowsException() {
|
||||
String encryptedToken = "abcdef"; // malformed token
|
||||
StepVerifier.create(userService.resetPasswordAfterForgotPassword(encryptedToken, null))
|
||||
.verifyError(AppsmithException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetPasswordAfterForgotPassword_WhenTokenDoesNotMatch_ThrowsException() {
|
||||
String testEmail = "abc@example.org";
|
||||
String token = getEncodedToken(testEmail, "123456789");
|
||||
|
||||
// ** check if token is not present in DB ** //
|
||||
// mock the passwordResetTokenRepository to return empty
|
||||
Mockito.when(passwordResetTokenRepository.findByEmail(testEmail)).thenReturn(Mono.empty());
|
||||
|
||||
StepVerifier.create(userService.resetPasswordAfterForgotPassword(token, null))
|
||||
.expectErrorMessage(AppsmithError.INVALID_PASSWORD_RESET.getMessage())
|
||||
.verify();
|
||||
|
||||
// ** check if token present but hash does not match ** //
|
||||
PasswordResetToken passwordResetToken = new PasswordResetToken();
|
||||
passwordResetToken.setTokenHash(passwordEncoder.encode("abcdef"));
|
||||
|
||||
// mock the passwordResetTokenRepository to return empty
|
||||
Mockito.when(passwordResetTokenRepository.findByEmail(testEmail)).thenReturn(Mono.just(passwordResetToken));
|
||||
|
||||
StepVerifier.create(userService.resetPasswordAfterForgotPassword(token, null))
|
||||
.expectErrorMessage(AppsmithError.GENERIC_BAD_REQUEST.getMessage(FieldName.TOKEN))
|
||||
.verify();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user