diff --git a/app/client/src/pages/UserAuth/ResetPassword.tsx b/app/client/src/pages/UserAuth/ResetPassword.tsx index 0260d10055..35312bd906 100644 --- a/app/client/src/pages/UserAuth/ResetPassword.tsx +++ b/app/client/src/pages/UserAuth/ResetPassword.tsx @@ -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; } diff --git a/app/client/src/sagas/userSagas.tsx b/app/client/src/sagas/userSagas.tsx index 8c96adfe43..fbf35a858d 100644 --- a/app/client/src/sagas/userSagas.tsx +++ b/app/client/src/sagas/userSagas.tsx @@ -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); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java index 97e9978408..d5f61000a1 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java @@ -121,8 +121,8 @@ public class UserController extends BaseController { } @GetMapping("/verifyPasswordResetToken") - public Mono> verifyPasswordResetToken(@RequestParam String email, @RequestParam String token) { - return service.verifyPasswordResetToken(email, token) + public Mono> verifyPasswordResetToken(@RequestParam String token) { + return service.verifyPasswordResetToken(token) .map(result -> new ResponseDTO<>(HttpStatus.OK.value(), result, null)); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/PasswordResetToken.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/PasswordResetToken.java index 73f9998d03..f364159239 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/PasswordResetToken.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/PasswordResetToken.java @@ -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 } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/EmailTokenDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/EmailTokenDTO.java new file mode 100644 index 0000000000..d233f946cf --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/EmailTokenDTO.java @@ -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; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java index f47bfd1fcd..473e16976c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java @@ -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; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserService.java index e86d35ba65..2677354463 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserService.java @@ -20,7 +20,7 @@ public interface UserService extends CrudService { Mono forgotPasswordTokenGenerate(ResetUserPasswordDTO resetUserPasswordDTO); - Mono verifyPasswordResetToken(String email, String token); + Mono verifyPasswordResetToken(String token); Mono resetPasswordAfterForgotPassword(String token, User user); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserServiceImpl.java index 90fda7010e..c53d0aab6b 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserServiceImpl.java @@ -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 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 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 i this.commonConfig = commonConfig; this.emailConfig = emailConfig; this.userChangedHandler = userChangedHandler; + this.encryptionService = encryptionService; } @Override @@ -196,7 +206,7 @@ public class UserServiceImpl extends BaseService 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 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 resetFlowMono = passwordResetTokenMono .flatMap(passwordResetTokenRepository::save) .flatMap(obj -> { + List 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 params = Map.of("resetUrl", resetUrl); @@ -234,88 +251,114 @@ public class UserServiceImpl extends BaseService 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 verifyPasswordResetToken(String email, String token) { + public Mono 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 resetPasswordAfterForgotPassword(String token, User user) { + public Mono 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 i }); } + private EmailTokenDTO parseValueFromEncryptedToken(String encryptedToken) { + String decryptString = encryptionService.decryptString(encryptedToken); + List nameValuePairs = URLEncodedUtils.parse(decryptString, StandardCharsets.UTF_8); + Map params = new HashMap<>(); + + for(NameValuePair nameValuePair : nameValuePairs) { + params.put(nameValuePair.getName(), nameValuePair.getValue()); + } + return new EmailTokenDTO(params.get("email"), params.get("token")); + } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserServiceTest.java index a389563a4c..76db370799 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserServiceTest.java @@ -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 userMono; Mono 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 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(); + } }