Skip to content

Lost password feature #755

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a2bd841
feature: Initial Implementation of `lost-password` api endpoint
irfan-ikhwa Feb 14, 2024
ceabd3b
feature: Initial Implementation of Email Sending Feature with dummy D…
irfan-ikhwa Feb 17, 2024
c4b18bd
feature: Initial Password reset flow implementation
irfan-ikhwa Feb 18, 2024
96081d3
feature: Added email template in OrganizationCommonSettings, Added pu…
irfan-ikhwa Feb 20, 2024
d5c63ea
feature: Added emailTemplate in common-settings.
irfan-ikhwa Feb 22, 2024
0a213f4
Merge branch 'dev' into lost-password-feature
aq-ikhwa-tech Feb 25, 2024
65d9e2a
Misc fixes
aq-ikhwa-tech Feb 25, 2024
39ad3ac
Misc fixes
aq-ikhwa-tech Feb 26, 2024
72518dc
Update application.props
aq-ikhwa-tech Feb 26, 2024
d384418
Update application.props to include smtp server auth
aq-ikhwa-tech Feb 26, 2024
810d669
Merge branch 'lowcoder-org:main' into lost-password-feature
aq-ikhwa-tech Feb 26, 2024
758f86f
Merge branch 'dev' into lost-password-feature
aq-ikhwa-tech Feb 26, 2024
0391d2a
feature: Added Env Variables for SMPT server
irfan-ikhwa Feb 27, 2024
bc74e8a
feature: Rename SMTP Env variable to ADMIN
irfan-ikhwa Feb 27, 2024
30dcd9c
Merge branch 'dev' into lost-password-feature
FalkWolsky Mar 5, 2024
5343945
changed the API response to empty in case user does not exist.
irfan-ikhwa Mar 13, 2024
833c22b
Added env Variables.
irfan-ikhwa Mar 13, 2024
c5447c3
Added ssl auth.
irfan-ikhwa Mar 14, 2024
e5613c5
updated email sender filed name
irfan-ikhwa Mar 14, 2024
a8b2ece
updated email sender filed name
irfan-ikhwa Mar 14, 2024
ac6eb29
Merge branch 'dev' into lost-password-feature
FalkWolsky Mar 14, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions server/api-service/lowcoder-domain/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
<groupId>org.lowcoder</groupId>
<artifactId>lowcoder-infra</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

<dependency>
<groupId>com.github.cloudyrock.mongock</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public OrganizationCommonSettings getCommonSettings() {
public static class OrganizationCommonSettings extends HashMap<String, Object> {
public static final String USER_EXTRA_TRANSFORMER = "userExtraTransformer";
public static final String USER_EXTRA_TRANSFORMER_UPDATE_TIME = "userExtraTransformer_updateTime";

public static final String PASSWORD_RESET_EMAIL_TEMPLATE = "passwordResetEmailTemplate";
// custom branding configs
public static final String CUSTOM_BRANDING_KEY = "branding";
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.lowcoder.domain.organization.service;

import static org.lowcoder.domain.authentication.AuthenticationService.DEFAULT_AUTH_CONFIG;
import static org.lowcoder.domain.organization.model.Organization.OrganizationCommonSettings.PASSWORD_RESET_EMAIL_TEMPLATE;
import static org.lowcoder.domain.organization.model.OrganizationState.ACTIVE;
import static org.lowcoder.domain.organization.model.OrganizationState.DELETED;
import static org.lowcoder.domain.util.QueryDslUtils.fieldName;
Expand Down Expand Up @@ -56,6 +57,12 @@ public class OrganizationServiceImpl implements OrganizationService {

private final Conf<Integer> logoMaxSizeInKb;

private static final String PASSWORD_RESET_EMAIL_TEMPLATE_DEFAULT = "<p>Hi, %s<br/>" +
"Here is the link to reset your password: %s<br/>" +
"Please note that the link will expire after 12 hours.<br/><br/>" +
"Regards,<br/>" +
"The Lowcoder Team</p>";

@Autowired
private AssetRepository assetRepository;

Expand Down Expand Up @@ -151,6 +158,9 @@ public Mono<Organization> create(Organization organization, String creatorId, bo
if (organization == null || StringUtils.isNotBlank(organization.getId())) {
return Mono.error(new BizException(BizError.INVALID_PARAMETER, "INVALID_PARAMETER", FieldName.ORGANIZATION));
}
organization.setCommonSettings(new OrganizationCommonSettings());
organization.getCommonSettings().put("PASSWORD_RESET_EMAIL_TEMPLATE",
PASSWORD_RESET_EMAIL_TEMPLATE_DEFAULT);
organization.setState(ACTIVE);
return Mono.just(organization);
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static com.google.common.base.Suppliers.memoize;
import static org.lowcoder.infra.util.AssetUtils.toAssetPath;

import java.time.Instant;
import java.util.*;
import java.util.function.Supplier;

Expand Down Expand Up @@ -52,6 +53,10 @@ public class User extends HasIdAndAuditing implements BeforeMongodbWrite, AfterM
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;

private String passwordResetToken;

private Instant passwordResetTokenExpiry;

@Transient
Boolean isAnonymous = false;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package org.lowcoder.domain.user.service;

import jakarta.mail.internet.MimeMessage;
import lombok.extern.slf4j.Slf4j;
import org.lowcoder.sdk.config.CommonConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

@Service
@Slf4j(topic = "EmailCommunicationService")
public class EmailCommunicationService {

@Autowired
private JavaMailSender javaMailSender;

@Autowired
private CommonConfig config;

public boolean sendPasswordResetEmail(String to, String token, String message) {
try {
String subject = "Reset Your Lost Password";
MimeMessage mimeMessage = javaMailSender.createMimeMessage();

MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, true);

mimeMessageHelper.setFrom(config.getLostPasswordEmailSender());
mimeMessageHelper.setTo(to);
mimeMessageHelper.setSubject(subject);

// Construct the message with the token link
String resetLink = config.getLowcoderPublicUrl() + "/lost-password?token=" + token;
String formattedMessage = String.format(message, to, resetLink);
mimeMessageHelper.setText(formattedMessage, true); // Set HTML to true to allow links

javaMailSender.send(mimeMessage);

return true;

} catch (Exception e) {
log.error("Failed to send mail to: {}, Exception: ", to, e);
return false;
}


}

}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ public interface UserService {

Mono<String> resetPassword(String userId);

Mono<Boolean> lostPassword(String userEmail);

Mono<Boolean> resetLostPassword(String userEmail, String token, String newPassword);

Mono<Boolean> setPassword(String userId, String password);

Mono<UserDetail> buildUserDetail(User user, boolean withoutDynamicGroups);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.lowcoder.domain.group.service.GroupService;
import org.lowcoder.domain.organization.model.OrgMember;
import org.lowcoder.domain.organization.service.OrgMemberService;
import org.lowcoder.domain.organization.service.OrganizationService;
import org.lowcoder.domain.user.model.*;
import org.lowcoder.domain.user.model.User.TransformedUserInfo;
import org.lowcoder.domain.user.repository.UserRepository;
Expand All @@ -29,6 +30,7 @@
import org.lowcoder.sdk.constants.WorkspaceMode;
import org.lowcoder.sdk.exception.BizError;
import org.lowcoder.sdk.exception.BizException;
import org.lowcoder.sdk.util.HashUtils;
import org.lowcoder.sdk.util.LocaleUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
Expand All @@ -40,6 +42,8 @@

import javax.annotation.Nonnull;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -69,12 +73,15 @@ public class UserServiceImpl implements UserService {
@Autowired
private OrgMemberService orgMemberService;
@Autowired
private OrganizationService organizationService;
@Autowired
private GroupService groupService;
@Autowired
private CommonConfig commonConfig;
@Autowired
private AuthenticationService authenticationService;

@Autowired
private EmailCommunicationService emailCommunicationService;
private Conf<Integer> avatarMaxSizeInKb;

@PostConstruct
Expand Down Expand Up @@ -262,6 +269,47 @@ public Mono<String> resetPassword(String userId) {
});
}

@Override
public Mono<Boolean> lostPassword(String userEmail) {
return findByName(userEmail)
.zipWhen(user -> orgMemberService.getCurrentOrgMember(user.getId())
.flatMap(orgMember -> organizationService.getById(orgMember.getOrgId()))
.map(organization -> organization.getCommonSettings().get("PASSWORD_RESET_EMAIL_TEMPLATE")))
.flatMap(tuple -> {
User user = tuple.getT1();
String emailTemplate = (String)tuple.getT2();

String token = generateNewRandomPwd();
Instant tokenExpiry = Instant.now().plus(12, ChronoUnit.HOURS);
if (!emailCommunicationService.sendPasswordResetEmail(userEmail, token, emailTemplate)) {
return Mono.empty();
}
user.setPasswordResetToken(HashUtils.hash(token.getBytes()));
user.setPasswordResetTokenExpiry(tokenExpiry);
return repository.save(user).then(Mono.empty());
});
}

@Override
public Mono<Boolean> resetLostPassword(String userEmail, String token, String newPassword) {
return findByName(userEmail)
.flatMap(user -> {
if (Instant.now().until(user.getPasswordResetTokenExpiry(), ChronoUnit.MINUTES) <= 0) {
return ofError(BizError.INVALID_PARAMETER, "TOKEN_EXPIRED");
}

if (!StringUtils.equals(HashUtils.hash(token.getBytes()), user.getPasswordResetToken())) {
return ofError(BizError.INVALID_PARAMETER, "INVALID_TOKEN");
}

user.setPassword(encryptionService.encryptPassword(newPassword));
user.setPasswordResetToken(StringUtils.EMPTY);
user.setPasswordResetTokenExpiry(Instant.now());
return repository.save(user)
.thenReturn(true);
});
}

@SuppressWarnings("SpellCheckingInspection")
@Nonnull
private static String generateNewRandomPwd() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public class CommonConfig {
private List<String> pluginDirs = new ArrayList<>();
private SuperAdmin superAdmin = new SuperAdmin();
private Marketplace marketplace = new Marketplace();
private String lowcoderPublicUrl;
private String lostPasswordEmailSender;

public boolean isSelfHost() {
return !isCloud();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ CANNOT_DELETE_SYSTEM_GROUP=System group cannot be deleted.
NEED_DEV_TO_CREATE_RESOURCE=Invalid operation, workspace developers or admin required.
UNABLE_TO_FIND_VALID_ORG=Cannot find a valid workspace for current user.
USER_BANNED=Current account is frozen.
SENDING_EMAIL_FAILED=Email could not be sent. Please check the smtp settings for the org.
TOKEN_EXPIRED=Token to reset the password has expired
INVALID_TOKEN=Invalid token received for password reset request
# invitation
INVALID_INVITATION_CODE=Invitation code not found.
ALREADY_IN_ORGANIZATION=You are already in this workspace.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {

ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, USER_URL + "/me"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, USER_URL + "/currentUser"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, USER_URL + "/lost-password"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, USER_URL + "/reset-lost-password"),

ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, GROUP_URL + "/list"), // application view
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, QUERY_URL + "/execute"), // application view
Expand All @@ -133,6 +135,8 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.APPLICATION_URL + "/marketplace-apps"), // marketplace apps
ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.USER_URL + "/me"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.USER_URL + "/currentUser"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, NewUrl.USER_URL + "/lost-password"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, NewUrl.USER_URL + "/reset-lost-password"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.GROUP_URL + "/list"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, NewUrl.QUERY_URL + "/execute"),
ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, NewUrl.MATERIAL_URL + "/**"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ public Mono<String> resetPassword(String userId) {
.then(userService.resetPassword(userId));
}

public Mono<Boolean> lostPassword(String userEmail) {
return userService.lostPassword(userEmail);
}

public Mono<Boolean> resetLostPassword(String userEmail, String token, String newPassword) {
return userService.resetLostPassword(userEmail, token, newPassword);
}

// ========================== TOKEN OPERATIONS START ==========================

public Mono<Void> saveToken(String userId, String source, String token) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,26 @@ public Mono<ResponseView<String>> resetPassword(@RequestBody ResetPasswordReques

}

@Override
public Mono<ResponseView<Boolean>> lostPassword(@RequestBody LostPasswordRequest request) {
if (StringUtils.isBlank(request.userEmail())) {
return Mono.empty();
}
return userApiService.lostPassword(request.userEmail())
.map(ResponseView::success);
}

@Override
public Mono<ResponseView<Boolean>> resetLostPassword(@RequestBody ResetLostPasswordRequest request) {
if (StringUtils.isBlank(request.userEmail()) || StringUtils.isBlank(request.token())
|| StringUtils.isBlank(request.newPassword())) {
return ofError(BizError.INVALID_PARAMETER, "INVALID_PARAMETER");
}

return userApiService.resetLostPassword(request.userEmail(), request.token(), request.newPassword())
.map(ResponseView::success);
}

@Override
public Mono<ResponseView<Boolean>> setPassword(@RequestParam String password) {
if (StringUtils.isBlank(password)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ public interface UserEndpoints
@PostMapping("/reset-password")
public Mono<ResponseView<String>> resetPassword(@RequestBody ResetPasswordRequest request);

@PostMapping("/lost-password")
public Mono<ResponseView<Boolean>> lostPassword(@RequestBody LostPasswordRequest request);

@PostMapping("/reset-lost-password")
public Mono<ResponseView<Boolean>> resetLostPassword(@RequestBody ResetLostPasswordRequest request);

@Operation(
tags = TAG_USER_PASSWORD_MANAGEMENT,
operationId = "setPassword",
Expand Down Expand Up @@ -151,6 +157,12 @@ public interface UserEndpoints
public record ResetPasswordRequest(String userId) {
}

public record LostPasswordRequest(String userEmail) {
}

public record ResetLostPasswordRequest(String token, String userEmail, String newPassword) {
}

public record UpdatePasswordRequest(String oldPassword, String newPassword) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ spring:
main:
allow-bean-definition-overriding: true
allow-circular-references: true
mail:
host: smtp.gmail.com
port: 587
username: yourmail@gmail.com
password: yourpass
properties:
mail:
smtp:
auth: true
ssl:
enable: false
starttls:
enable: true
required: true
transport:
protocol: smtp

logging:
level:
Expand Down Expand Up @@ -58,6 +74,8 @@ common:
password: Password@123
marketplace:
private-mode: false
lowcoder-public-url: http://localhost:8080
notifications-email-sender: info@lowcoder.org

material:
mongodb-grid-fs:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,22 @@ spring:
max-in-memory-size: 20MB
webflux:
base-path: /

mail:
host: ${LOWCODER_ADMIN_SMTP_HOST:smtp.gmail.com}
port: ${LOWCODER_ADMIN_SMTP_PORT:587}
username: ${LOWCODER_ADMIN_SMTP_USERNAME:yourmail@gmail.com}
password: ${LOWCODER_ADMIN_SMTP_PASSWORD:yourpass}
properties:
mail:
smtp:
auth: ${LOWCODER_ADMIN_SMTP_AUTH:true}
ssl:
enable: ${LOWCODER_ADMIN_SMTP_SSL_ENABLED:false}
starttls:
enable: ${LOWCODER_ADMIN_SMTP_STARTTLS_ENABLED:true}
required: ${LOWCODER_ADMIN_SMTP_STARTTLS_REQUIRED:true}
transport:
protocol: smtp
server:
compression:
enabled: true
Expand Down Expand Up @@ -57,6 +72,8 @@ common:
- ${LOWCODER_PLUGINS_DIR:plugins}
marketplace:
private-mode: ${LOWCODER_MARKETPLACE_PRIVATE_MODE:true}
lowcoder-public-url: ${LOWCODER_PUBLIC_URL:http://localhost:8080}
notifications-email-sender: ${LOWCODER_LOST_PASSWORD_EMAIL_SENDER:info@lowcoder.org}

material:
mongodb-grid-fs:
Expand Down