[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< type ResetPasswordProps = InjectedFormProps<
ResetPasswordFormValues, ResetPasswordFormValues,
{ {
verifyToken: (token: string, email: string) => void; verifyToken: (token: string) => void;
isTokenValid: boolean; isTokenValid: boolean;
validatingToken: boolean; validatingToken: boolean;
} }
> & { > & {
verifyToken: (token: string, email: string) => void; verifyToken: (token: string) => void;
isTokenValid: boolean; isTokenValid: boolean;
validatingToken: boolean; validatingToken: boolean;
theme: Theme; theme: Theme;
@ -83,11 +83,10 @@ export function ResetPassword(props: ResetPasswordProps) {
} = props; } = props;
useLayoutEffect(() => { useLayoutEffect(() => {
if (initialValues.token && initialValues.email) if (initialValues.token) verifyToken(initialValues.token);
verifyToken(initialValues.token, initialValues.email); }, [initialValues.token, verifyToken]);
}, [initialValues.token, initialValues.email, verifyToken]);
const showInvalidMessage = !initialValues.token || !initialValues.email; const showInvalidMessage = !initialValues.token;
const showExpiredMessage = !isTokenValid && !validatingToken; const showExpiredMessage = !isTokenValid && !validatingToken;
const showSuccessMessage = submitSucceeded && !pristine; const showSuccessMessage = submitSucceeded && !pristine;
const showFailureMessage = submitFailed && !!error; const showFailureMessage = submitFailed && !!error;
@ -209,7 +208,6 @@ export default connect(
const queryParams = new URLSearchParams(props.location.search); const queryParams = new URLSearchParams(props.location.search);
return { return {
initialValues: { initialValues: {
email: queryParams.get("email") || undefined,
token: queryParams.get("token") || undefined, token: queryParams.get("token") || undefined,
}, },
isTokenValid: getIsTokenValid(state), isTokenValid: getIsTokenValid(state),
@ -217,17 +215,17 @@ export default connect(
}; };
}, },
(dispatch: any) => ({ (dispatch: any) => ({
verifyToken: (token: string, email: string) => verifyToken: (token: string) =>
dispatch({ dispatch({
type: ReduxActionTypes.RESET_PASSWORD_VERIFY_TOKEN_INIT, type: ReduxActionTypes.RESET_PASSWORD_VERIFY_TOKEN_INIT,
payload: { token, email }, payload: { token },
}), }),
}), }),
)( )(
reduxForm< reduxForm<
ResetPasswordFormValues, ResetPasswordFormValues,
{ {
verifyToken: (token: string, email: string) => void; verifyToken: (token: string) => void;
validatingToken: boolean; validatingToken: boolean;
isTokenValid: boolean; isTokenValid: boolean;
} }

View File

@ -318,10 +318,14 @@ export function* verifyResetPasswordTokenSaga(
request, request,
); );
const isValidResponse = yield validateResponse(response); const isValidResponse = yield validateResponse(response);
if (isValidResponse) { if (isValidResponse && response.data) {
yield put({ yield put({
type: ReduxActionTypes.RESET_PASSWORD_VERIFY_TOKEN_SUCCESS, type: ReduxActionTypes.RESET_PASSWORD_VERIFY_TOKEN_SUCCESS,
}); });
} else {
yield put({
type: ReduxActionErrorTypes.RESET_PASSWORD_VERIFY_TOKEN_ERROR,
});
} }
} catch (error) { } catch (error) {
log.error(error); log.error(error);

View File

@ -121,8 +121,8 @@ public class UserController extends BaseController<UserService, User, String> {
} }
@GetMapping("/verifyPasswordResetToken") @GetMapping("/verifyPasswordResetToken")
public Mono<ResponseDTO<Boolean>> verifyPasswordResetToken(@RequestParam String email, @RequestParam String token) { public Mono<ResponseDTO<Boolean>> verifyPasswordResetToken(@RequestParam String token) {
return service.verifyPasswordResetToken(email, token) return service.verifyPasswordResetToken(token)
.map(result -> new ResponseDTO<>(HttpStatus.OK.value(), result, null)); .map(result -> new ResponseDTO<>(HttpStatus.OK.value(), result, null));
} }

View File

@ -7,15 +7,16 @@ import lombok.Setter;
import lombok.ToString; import lombok.ToString;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
@Getter @Getter
@Setter @Setter
@ToString @ToString
@NoArgsConstructor @NoArgsConstructor
@Document @Document
public class PasswordResetToken extends BaseDomain { public class PasswordResetToken extends BaseDomain {
String tokenHash; String tokenHash;
//Password Reset Token should be valid only for a specified amount of time.
String email; 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), 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), 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), 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; private final Integer httpErrorCode;

View File

@ -20,7 +20,7 @@ public interface UserService extends CrudService<User, String> {
Mono<Boolean> forgotPasswordTokenGenerate(ResetUserPasswordDTO resetUserPasswordDTO); Mono<Boolean> forgotPasswordTokenGenerate(ResetUserPasswordDTO resetUserPasswordDTO);
Mono<Boolean> verifyPasswordResetToken(String email, String token); Mono<Boolean> verifyPasswordResetToken(String token);
Mono<Boolean> resetPasswordAfterForgotPassword(String token, User user); 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.helpers.BeanCopyUtils;
import com.appsmith.external.models.Policy; import com.appsmith.external.models.Policy;
import com.appsmith.external.services.EncryptionService;
import com.appsmith.server.acl.AclPermission; import com.appsmith.server.acl.AclPermission;
import com.appsmith.server.acl.AppsmithRole; import com.appsmith.server.acl.AppsmithRole;
import com.appsmith.server.acl.RoleGraph; 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.QUser;
import com.appsmith.server.domains.User; import com.appsmith.server.domains.User;
import com.appsmith.server.domains.UserRole; import com.appsmith.server.domains.UserRole;
import com.appsmith.server.dtos.EmailTokenDTO;
import com.appsmith.server.dtos.InviteUsersDTO; import com.appsmith.server.dtos.InviteUsersDTO;
import com.appsmith.server.dtos.ResetUserPasswordDTO; import com.appsmith.server.dtos.ResetUserPasswordDTO;
import com.appsmith.server.dtos.UserProfileDTO; 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.repositories.UserRepository;
import com.appsmith.server.solutions.UserChangedHandler; import com.appsmith.server.solutions.UserChangedHandler;
import lombok.extern.slf4j.Slf4j; 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.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.convert.MongoConverter; import org.springframework.data.mongodb.core.convert.MongoConverter;
@ -48,6 +53,8 @@ import reactor.core.scheduler.Scheduler;
import javax.validation.Validator; import javax.validation.Validator;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@ -83,10 +90,11 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
private final CommonConfig commonConfig; private final CommonConfig commonConfig;
private final EmailConfig emailConfig; private final EmailConfig emailConfig;
private final UserChangedHandler userChangedHandler; private final UserChangedHandler userChangedHandler;
private final EncryptionService encryptionService;
private static final String WELCOME_USER_EMAIL_TEMPLATE = "email/welcomeUserTemplate.html"; 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_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_CLIENT_URL_FORMAT = "%s/user/signup?email=%s";
private static final String INVITE_USER_EMAIL_TEMPLATE = "email/inviteUserCreatorTemplate.html"; private static final String INVITE_USER_EMAIL_TEMPLATE = "email/inviteUserCreatorTemplate.html";
private static final String USER_ADDED_TO_ORGANIZATION_EMAIL_TEMPLATE = "email/inviteExistingUserToOrganizationTemplate.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, ConfigService configService,
CommonConfig commonConfig, CommonConfig commonConfig,
EmailConfig emailConfig, EmailConfig emailConfig,
UserChangedHandler userChangedHandler) { UserChangedHandler userChangedHandler,
EncryptionService encryptionService) {
super(scheduler, validator, mongoConverter, reactiveMongoTemplate, repository, analyticsService); super(scheduler, validator, mongoConverter, reactiveMongoTemplate, repository, analyticsService);
this.organizationService = organizationService; this.organizationService = organizationService;
this.sessionUserService = sessionUserService; this.sessionUserService = sessionUserService;
@ -127,6 +136,7 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
this.commonConfig = commonConfig; this.commonConfig = commonConfig;
this.emailConfig = emailConfig; this.emailConfig = emailConfig;
this.userChangedHandler = userChangedHandler; this.userChangedHandler = userChangedHandler;
this.encryptionService = encryptionService;
} }
@Override @Override
@ -196,7 +206,7 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
String email = resetUserPasswordDTO.getEmail(); String email = resetUserPasswordDTO.getEmail();
// Create a random token to be sent out. // 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); 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 // 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(() -> { .switchIfEmpty(Mono.defer(() -> {
PasswordResetToken passwordResetToken = new PasswordResetToken(); PasswordResetToken passwordResetToken = new PasswordResetToken();
passwordResetToken.setEmail(email); passwordResetToken.setEmail(email);
passwordResetToken.setRequestCount(1);
passwordResetToken.setFirstRequestTime(Instant.now());
return Mono.just(passwordResetToken); return Mono.just(passwordResetToken);
})) }))
.map(resetToken -> { .map(resetToken -> {
// check the validity of the token
validateResetLimit(resetToken);
resetToken.setTokenHash(passwordEncoder.encode(token)); resetToken.setTokenHash(passwordEncoder.encode(token));
return resetToken; 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 Mono<Boolean> resetFlowMono = passwordResetTokenMono
.flatMap(passwordResetTokenRepository::save) .flatMap(passwordResetTokenRepository::save)
.flatMap(obj -> { .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( String resetUrl = String.format(
FORGOT_PASSWORD_CLIENT_URL_FORMAT, FORGOT_PASSWORD_CLIENT_URL_FORMAT,
resetUserPasswordDTO.getBaseUrl(), resetUserPasswordDTO.getBaseUrl(),
URLEncoder.encode(token, StandardCharsets.UTF_8), encryptionService.encryptString(urlParams)
URLEncoder.encode(email, StandardCharsets.UTF_8)
); );
Map<String, String> params = Map.of("resetUrl", resetUrl); Map<String, String> params = Map.of("resetUrl", resetUrl);
@ -234,68 +251,92 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
params params
); );
}) })
.thenReturn(true) .thenReturn(true);
.onErrorResume(error -> {
log.error("Unable to send email because the template replacement failed. Cause: ", error);
return Mono.just(true);
});
// Connect the components to first find a valid user and then initiate the password reset flow // Connect the components to first find a valid user and then initiate the password reset flow
return userMono.then(resetFlowMono); 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 * 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 * user has already initiated a password reset request via the 'Forgot Password' link. The tokens are stored in the
* DB using BCrypt hash. * DB using BCrypt hash.
* *
* @param email The email of the user whose password is being reset * @param encryptedToken The one-time token provided to the user for resetting the password
* @param token 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 * @return Publishes a boolean indicating whether the given token is valid for the given email address
*/ */
@Override @Override
public Mono<Boolean> verifyPasswordResetToken(String email, String token) { public Mono<Boolean> verifyPasswordResetToken(String encryptedToken) {
EmailTokenDTO emailTokenDTO;
return passwordResetTokenRepository try {
.findByEmail(email) emailTokenDTO = parseValueFromEncryptedToken(encryptedToken);
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.EMAIL, email))) } catch (IllegalStateException e) {
.flatMap(obj -> { return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.TOKEN));
boolean matches = this.passwordEncoder.matches(token, obj.getTokenHash());
if (!matches) {
return Mono.just(false);
} }
return repository return passwordResetTokenRepository
.findByEmail(email) .findByEmail(emailTokenDTO.getEmail())
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.USER, email))) .switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.INVALID_PASSWORD_RESET)))
.map(user -> { .map(obj -> this.passwordEncoder.matches(emailTokenDTO.getToken(), obj.getTokenHash()));
user.setPasswordResetInitiated(true);
return user;
})
.flatMap(repository::save)
// Everything went fine till now. Cheerio!
.thenReturn(true);
});
} }
/** /**
* This function resets the password using the one-time token & email of the user. * 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. * 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 * @param user The user object that contains the email & password fields in order to save the new password for the user
* @return * @return
*/ */
@Override @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 return passwordResetTokenRepository
.findByEmail(user.getEmail()) .findByEmail(emailTokenDTO.getEmail())
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.USER, user.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 -> { .flatMap(userFromDb -> {
if (!userFromDb.getPasswordResetInitiated()) { if(!ValidationUtils.validateLoginPassword(user.getPassword())){
return Mono.error(new AppsmithException(AppsmithError.INVALID_PASSWORD_RESET));
} else if(!ValidationUtils.validateLoginPassword(user.getPassword())){
return Mono.error(new AppsmithException( return Mono.error(new AppsmithException(
AppsmithError.INVALID_PASSWORD_LENGTH, LOGIN_PASSWORD_MIN_LENGTH, LOGIN_PASSWORD_MAX_LENGTH) AppsmithError.INVALID_PASSWORD_LENGTH, LOGIN_PASSWORD_MIN_LENGTH, LOGIN_PASSWORD_MAX_LENGTH)
); );
@ -310,12 +351,14 @@ public class UserServiceImpl extends BaseService<UserRepository, User, String> i
userFromDb.setIsEnabled(true); userFromDb.setIsEnabled(true);
return passwordResetTokenRepository return passwordResetTokenRepository
.findByEmail(user.getEmail()) .findByEmail(userFromDb.getEmail())
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.TOKEN, token))) .switchIfEmpty(Mono.error(new AppsmithException(
AppsmithError.NO_RESOURCE_FOUND, FieldName.TOKEN, emailTokenDTO.getToken()
)))
.flatMap(passwordResetTokenRepository::delete) .flatMap(passwordResetTokenRepository::delete)
.then(repository.save(userFromDb)) .then(repository.save(userFromDb))
.thenReturn(true); .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; package com.appsmith.server.services;
import com.appsmith.external.models.Policy; import com.appsmith.external.models.Policy;
import com.appsmith.external.services.EncryptionService;
import com.appsmith.server.acl.AppsmithRole; import com.appsmith.server.acl.AppsmithRole;
import com.appsmith.server.configurations.WithMockAppsmithUser; import com.appsmith.server.configurations.WithMockAppsmithUser;
import com.appsmith.server.constants.FieldName; 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.InviteUser;
import com.appsmith.server.domains.LoginSource; import com.appsmith.server.domains.LoginSource;
import com.appsmith.server.domains.Organization; import com.appsmith.server.domains.Organization;
import com.appsmith.server.domains.PasswordResetToken;
import com.appsmith.server.domains.User; import com.appsmith.server.domains.User;
import com.appsmith.server.dtos.InviteUsersDTO; import com.appsmith.server.dtos.InviteUsersDTO;
import com.appsmith.server.dtos.ResetUserPasswordDTO;
import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.repositories.PasswordResetTokenRepository;
import com.appsmith.server.repositories.UserRepository; import com.appsmith.server.repositories.UserRepository;
import com.appsmith.server.solutions.UserSignup; import com.appsmith.server.solutions.UserSignup;
import lombok.extern.slf4j.Slf4j; 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.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; 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.crypto.password.PasswordEncoder;
import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.security.test.context.support.WithUserDetails;
import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.annotation.DirtiesContext;
@ -31,6 +39,8 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.test.StepVerifier; import reactor.test.StepVerifier;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
@ -69,6 +79,12 @@ public class UserServiceTest {
@Autowired @Autowired
PasswordEncoder passwordEncoder; PasswordEncoder passwordEncoder;
@Autowired
EncryptionService encryptionService;
@MockBean
PasswordResetTokenRepository passwordResetTokenRepository;
Mono<User> userMono; Mono<User> userMono;
Mono<Organization> organizationMono; 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();
}
} }