[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:
Nayan 2021-08-17 18:35:00 +06:00 committed by GitHub
parent a80c92694b
commit 919a420aa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 257 additions and 75 deletions

View File

@ -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;
}

View File

@ -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);

View File

@ -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));
}

View File

@ -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
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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"));
}
}

View File

@ -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();
}
}