[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<
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user