diff --git a/pom.xml b/pom.xml index 8986fb2..2e6a7ca 100644 --- a/pom.xml +++ b/pom.xml @@ -18,10 +18,28 @@ 1.4.2.Final + + org.passay + passay + 1.6.0 + + + com.iamnbty.training + common + 0.0.1-SNAPSHOT + org.springframework.boot spring-boot-starter-actuator + + org.springframework.kafka + spring-kafka + + + org.springframework.kafka + spring-kafka-test + org.springframework.boot spring-boot-starter-web diff --git a/src/main/java/com/iamnbty/training/backend/api/UserApi.java b/src/main/java/com/iamnbty/training/backend/api/UserApi.java index 492203f..da92a5d 100644 --- a/src/main/java/com/iamnbty/training/backend/api/UserApi.java +++ b/src/main/java/com/iamnbty/training/backend/api/UserApi.java @@ -2,10 +2,8 @@ import com.iamnbty.training.backend.business.UserBusiness; import com.iamnbty.training.backend.exception.BaseException; -import com.iamnbty.training.backend.model.MLoginRequest; -import com.iamnbty.training.backend.model.MLoginResponse; -import com.iamnbty.training.backend.model.MRegisterRequest; -import com.iamnbty.training.backend.model.MRegisterResponse; +import com.iamnbty.training.backend.model.*; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -32,6 +30,19 @@ public ResponseEntity register(@RequestBody MRegisterRequest return ResponseEntity.ok(response); } + @PostMapping("/activate") + public ResponseEntity activate(@RequestBody MActivateRequest request) throws BaseException { + MActivateResponse response = business.activate(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/resend-activation-email") + public ResponseEntity resendActivationEmail(@RequestBody MResendActivationEmailRequest request) throws BaseException { + business.resendActivationEmail(request); + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @GetMapping("/refresh-token") public ResponseEntity refreshToken() throws BaseException { String response = business.refreshToken(); diff --git a/src/main/java/com/iamnbty/training/backend/business/EmailBusiness.java b/src/main/java/com/iamnbty/training/backend/business/EmailBusiness.java index e183c20..b71c2ab 100644 --- a/src/main/java/com/iamnbty/training/backend/business/EmailBusiness.java +++ b/src/main/java/com/iamnbty/training/backend/business/EmailBusiness.java @@ -2,22 +2,28 @@ import com.iamnbty.training.backend.exception.BaseException; import com.iamnbty.training.backend.exception.EmailException; -import com.iamnbty.training.backend.service.EmailService; +import com.iamnbty.training.common.EmailRequest; +import lombok.extern.log4j.Log4j2; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; import org.springframework.stereotype.Service; import org.springframework.util.FileCopyUtils; import org.springframework.util.ResourceUtils; +import org.springframework.util.concurrent.ListenableFuture; +import org.springframework.util.concurrent.ListenableFutureCallback; import java.io.File; import java.io.FileReader; import java.io.IOException; @Service +@Log4j2 public class EmailBusiness { - private final EmailService emailService; + private final KafkaTemplate kafkaEmailTemplate; - public EmailBusiness(EmailService emailService) { - this.emailService = emailService; + public EmailBusiness(KafkaTemplate kafkaEmailTemplate) { + this.kafkaEmailTemplate = kafkaEmailTemplate; } public void sendActivateUserEmail(String email, String name, String token) throws BaseException { @@ -29,15 +35,31 @@ public void sendActivateUserEmail(String email, String name, String token) throw throw EmailException.templateNotFound(); } + log.info("Token = " + token); + String finalLink = "http://localhost:4200/activate/" + token; html = html.replace("${P_NAME}", name); - html = html.replace("${LINK}", finalLink); + html = html.replace("${P_LINK}", finalLink); - // prepare subject - String subject = "Please activate your account"; + EmailRequest request = new EmailRequest(); + request.setTo(email); + request.setSubject("Please activate your account"); + request.setContent(html); + ListenableFuture> future = kafkaEmailTemplate.send("activation-email", request); + future.addCallback(new ListenableFutureCallback<>() { + @Override + public void onFailure(Throwable throwable) { + log.error("Kafka send fail"); + log.error(throwable); + } - emailService.send(email, subject, html); + @Override + public void onSuccess(SendResult result) { + log.info("Kafka send success"); + log.info(result); + } + }); } private String readEmailTemplate(String filename) throws IOException { diff --git a/src/main/java/com/iamnbty/training/backend/business/UserBusiness.java b/src/main/java/com/iamnbty/training/backend/business/UserBusiness.java index 45a32d9..0ef4d29 100644 --- a/src/main/java/com/iamnbty/training/backend/business/UserBusiness.java +++ b/src/main/java/com/iamnbty/training/backend/business/UserBusiness.java @@ -5,22 +5,20 @@ import com.iamnbty.training.backend.exception.FileException; import com.iamnbty.training.backend.exception.UserException; import com.iamnbty.training.backend.mapper.UserMapper; -import com.iamnbty.training.backend.model.MLoginRequest; -import com.iamnbty.training.backend.model.MLoginResponse; -import com.iamnbty.training.backend.model.MRegisterRequest; -import com.iamnbty.training.backend.model.MRegisterResponse; +import com.iamnbty.training.backend.model.*; import com.iamnbty.training.backend.service.TokenService; import com.iamnbty.training.backend.service.UserService; import com.iamnbty.training.backend.util.SecurityUtil; +import io.netty.util.internal.StringUtil; +import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; +import java.util.*; @Service +@Log4j2 public class UserBusiness { private final UserService userService; @@ -29,10 +27,13 @@ public class UserBusiness { private final UserMapper userMapper; - public UserBusiness(UserService userService, TokenService tokenService, UserMapper userMapper) { + private final EmailBusiness emailBusiness; + + public UserBusiness(UserService userService, TokenService tokenService, UserMapper userMapper, EmailBusiness emailBusiness) { this.userService = userService; this.tokenService = tokenService; this.userMapper = userMapper; + this.emailBusiness = emailBusiness; } public MLoginResponse login(MLoginRequest request) throws BaseException { @@ -45,10 +46,17 @@ public MLoginResponse login(MLoginRequest request) throws BaseException { } User user = opt.get(); + + // verify password if (!userService.matchPassword(request.getPassword(), user.getPassword())) { throw UserException.loginFailPasswordIncorrect(); } + // verify activate status + if (!user.isActivated()) { + throw UserException.loginFailUserUnactivated(); + } + MLoginResponse response = new MLoginResponse(); response.setToken(tokenService.tokenize(user)); return response; @@ -72,11 +80,85 @@ public String refreshToken() throws BaseException { } public MRegisterResponse register(MRegisterRequest request) throws BaseException { - User user = userService.create(request.getEmail(), request.getPassword(), request.getName()); + String token = SecurityUtil.generateToken(); + User user = userService.create(request.getEmail(), request.getPassword(), request.getName(), token, nextXMinute(30)); + + sendEmail(user); return userMapper.toRegisterResponse(user); } + public MActivateResponse activate(MActivateRequest request) throws BaseException { + String token = request.getToken(); + if (StringUtil.isNullOrEmpty(token)) { + throw UserException.activateNoToken(); + } + + Optional opt = userService.findByToken(token); + if (opt.isEmpty()) { + throw UserException.activateFail(); + } + + User user = opt.get(); + + if (user.isActivated()) { + throw UserException.activateAlready(); + } + + Date now = new Date(); + Date expireDate = user.getTokenExpire(); + if (now.after(expireDate)) { + throw UserException.activateTokenExpire(); + } + + user.setActivated(true); + userService.update(user); + + MActivateResponse response = new MActivateResponse(); + response.setSuccess(true); + return response; + } + + public void resendActivationEmail(MResendActivationEmailRequest request) throws BaseException { + String email = request.getEmail(); + if (StringUtil.isNullOrEmpty(email)) { + throw UserException.resendActivationEmailNoEmail(); + } + + Optional opt = userService.findByEmail(email); + if (opt.isEmpty()) { + throw UserException.resendActivationEmailNotFound(); + } + + User user = opt.get(); + + if (user.isActivated()) { + throw UserException.activateAlready(); + } + + user.setToken(SecurityUtil.generateToken()); + user.setTokenExpire(nextXMinute(30)); + user = userService.update(user); + + sendEmail(user); + } + + private Date nextXMinute(int minute) { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, minute); + return calendar.getTime(); + } + + private void sendEmail(User user) { + String token = user.getToken(); + + try { + emailBusiness.sendActivateUserEmail(user.getEmail(), user.getName(), token); + } catch (BaseException e) { + e.printStackTrace(); + } + } + public String uploadProfilePicture(MultipartFile file) throws BaseException { // validate file if (file == null) { diff --git a/src/main/java/com/iamnbty/training/backend/config/KafkaConfig.java b/src/main/java/com/iamnbty/training/backend/config/KafkaConfig.java new file mode 100644 index 0000000..4e68355 --- /dev/null +++ b/src/main/java/com/iamnbty/training/backend/config/KafkaConfig.java @@ -0,0 +1,39 @@ +package com.iamnbty.training.backend.config; + +import com.iamnbty.training.common.EmailRequest; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String server; + + @Bean + public Map producerConfigs() { + Map map = new HashMap<>(); + + map.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, server); + map.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + map.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + + return map; + } + + @Bean + public KafkaTemplate kafkaEmailTemplate() { + DefaultKafkaProducerFactory factory = new DefaultKafkaProducerFactory<>(producerConfigs()); + return new KafkaTemplate<>(factory); + } + +} diff --git a/src/main/java/com/iamnbty/training/backend/config/SecurityConfig.java b/src/main/java/com/iamnbty/training/backend/config/SecurityConfig.java index 965d9c8..24c650a 100644 --- a/src/main/java/com/iamnbty/training/backend/config/SecurityConfig.java +++ b/src/main/java/com/iamnbty/training/backend/config/SecurityConfig.java @@ -23,6 +23,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { "/actuator/**", "/user/register", "/user/login", + "/user/activate", + "/user/resend-activation-email", "/socket/**" }; diff --git a/src/main/java/com/iamnbty/training/backend/entity/User.java b/src/main/java/com/iamnbty/training/backend/entity/User.java index 1564fdc..6351886 100644 --- a/src/main/java/com/iamnbty/training/backend/entity/User.java +++ b/src/main/java/com/iamnbty/training/backend/entity/User.java @@ -4,6 +4,7 @@ import lombok.EqualsAndHashCode; import javax.persistence.*; +import java.util.Date; import java.util.List; @EqualsAndHashCode(callSuper = true) @@ -28,4 +29,10 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", orphanRemoval = true, fetch = FetchType.EAGER) private List
addresses; + private String token; + + private Date tokenExpire; + + private boolean activated; + } diff --git a/src/main/java/com/iamnbty/training/backend/exception/UserException.java b/src/main/java/com/iamnbty/training/backend/exception/UserException.java index adfa912..13628b4 100644 --- a/src/main/java/com/iamnbty/training/backend/exception/UserException.java +++ b/src/main/java/com/iamnbty/training/backend/exception/UserException.java @@ -50,4 +50,36 @@ public static UserException loginFailPasswordIncorrect() { return new UserException("login.fail"); } + public static UserException loginFailUserUnactivated() { + return new UserException("login.fail.unactivated"); + } + + // ACTIVATE + + public static UserException activateNoToken() { + return new UserException("activate.no.token"); + } + + public static UserException activateAlready() { + return new UserException("activate.already"); + } + + public static UserException activateFail() { + return new UserException("activate.fail"); + } + + public static UserException activateTokenExpire() { + return new UserException("activate.token.expire"); + } + + // RESEND ACTIVATION EMAIL + + public static UserException resendActivationEmailNoEmail() { + return new UserException("resend.activation.no.email"); + } + + public static UserException resendActivationEmailNotFound() { + return new UserException("resend.activation.fail"); + } + } \ No newline at end of file diff --git a/src/main/java/com/iamnbty/training/backend/model/MActivateRequest.java b/src/main/java/com/iamnbty/training/backend/model/MActivateRequest.java new file mode 100644 index 0000000..e996ec3 --- /dev/null +++ b/src/main/java/com/iamnbty/training/backend/model/MActivateRequest.java @@ -0,0 +1,10 @@ +package com.iamnbty.training.backend.model; + +import lombok.Data; + +@Data +public class MActivateRequest { + + private String token; + +} diff --git a/src/main/java/com/iamnbty/training/backend/model/MActivateResponse.java b/src/main/java/com/iamnbty/training/backend/model/MActivateResponse.java new file mode 100644 index 0000000..3f3ae5a --- /dev/null +++ b/src/main/java/com/iamnbty/training/backend/model/MActivateResponse.java @@ -0,0 +1,10 @@ +package com.iamnbty.training.backend.model; + +import lombok.Data; + +@Data +public class MActivateResponse { + + private boolean success; + +} diff --git a/src/main/java/com/iamnbty/training/backend/model/MResendActivationEmailRequest.java b/src/main/java/com/iamnbty/training/backend/model/MResendActivationEmailRequest.java new file mode 100644 index 0000000..7c6cd3a --- /dev/null +++ b/src/main/java/com/iamnbty/training/backend/model/MResendActivationEmailRequest.java @@ -0,0 +1,10 @@ +package com.iamnbty.training.backend.model; + +import lombok.Data; + +@Data +public class MResendActivationEmailRequest { + + private String email; + +} diff --git a/src/main/java/com/iamnbty/training/backend/repository/UserRepository.java b/src/main/java/com/iamnbty/training/backend/repository/UserRepository.java index 7e5178c..1a222e6 100644 --- a/src/main/java/com/iamnbty/training/backend/repository/UserRepository.java +++ b/src/main/java/com/iamnbty/training/backend/repository/UserRepository.java @@ -9,6 +9,8 @@ public interface UserRepository extends CrudRepository { Optional findByEmail(String email); + Optional findByToken(String token); + boolean existsByEmail(String email); } diff --git a/src/main/java/com/iamnbty/training/backend/service/EmailService.java b/src/main/java/com/iamnbty/training/backend/service/EmailService.java deleted file mode 100644 index ee6cf54..0000000 --- a/src/main/java/com/iamnbty/training/backend/service/EmailService.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.iamnbty.training.backend.service; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.mail.javamail.MimeMessagePreparator; -import org.springframework.stereotype.Service; - -@Service -public class EmailService { - - private final JavaMailSender mailSender; - @Value("${app.email.from}") - private String from; - - public EmailService(JavaMailSender mailSender) { - this.mailSender = mailSender; - } - - public void send(String to, String subject, String html) { - MimeMessagePreparator message = mimeMessage -> { - MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); - helper.setFrom(from); - helper.setTo(to); - helper.setSubject(subject); - helper.setText(html, true); - }; - mailSender.send(message); - } - -} diff --git a/src/main/java/com/iamnbty/training/backend/service/UserService.java b/src/main/java/com/iamnbty/training/backend/service/UserService.java index 20f3e6b..e73b1f4 100644 --- a/src/main/java/com/iamnbty/training/backend/service/UserService.java +++ b/src/main/java/com/iamnbty/training/backend/service/UserService.java @@ -7,6 +7,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.util.Calendar; +import java.util.Date; import java.util.Objects; import java.util.Optional; @@ -26,6 +28,10 @@ public Optional findById(String id) { return repository.findById(id); } + public Optional findByToken(String token) { + return repository.findByToken(token); + } + public Optional findByEmail(String email) { return repository.findByEmail(email); } @@ -54,7 +60,7 @@ public boolean matchPassword(String rawPassword, String encodedPassword) { return passwordEncoder.matches(rawPassword, encodedPassword); } - public User create(String email, String password, String name) throws BaseException { + public User create(String email, String password, String name, String token, Date tokenExpireDate) throws BaseException { // validate if (Objects.isNull(email)) { throw UserException.createEmailNull(); @@ -78,6 +84,8 @@ public User create(String email, String password, String name) throws BaseExcept entity.setEmail(email); entity.setPassword(passwordEncoder.encode(password)); entity.setName(name); + entity.setToken(token); + entity.setTokenExpire(tokenExpireDate); return repository.save(entity); } diff --git a/src/main/java/com/iamnbty/training/backend/util/SecurityUtil.java b/src/main/java/com/iamnbty/training/backend/util/SecurityUtil.java index 0808771..7b22ac1 100644 --- a/src/main/java/com/iamnbty/training/backend/util/SecurityUtil.java +++ b/src/main/java/com/iamnbty/training/backend/util/SecurityUtil.java @@ -1,9 +1,14 @@ package com.iamnbty.training.backend.util; +import org.passay.CharacterRule; +import org.passay.EnglishCharacterData; +import org.passay.PasswordGenerator; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import java.util.Arrays; +import java.util.List; import java.util.Optional; public class SecurityUtil { @@ -32,4 +37,19 @@ public static Optional getCurrentUserId() { return Optional.of(userId); } + + public static String generateToken() { + List rules = Arrays.asList( + new CharacterRule(EnglishCharacterData.UpperCase, 10), + + new CharacterRule(EnglishCharacterData.LowerCase, 10), + + new CharacterRule(EnglishCharacterData.Digit, 10) + ); + + PasswordGenerator generator = new PasswordGenerator(); + + return generator.generatePassword(30, rules); + } + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e88f180..3aa19ba 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,15 +1,10 @@ +server: + port: 8080 spring: - mail: - host: smtp.gmail.com - port: 587 - username: YOUR_USERNAME - password: YOUR_PASSWORD - properties: - mail: - smtp: - auth: true - starttls: - enable: true + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: "my-awesome-app" jpa: hibernate: ddl-auto: update diff --git a/src/main/resources/email/email-activate-user.html b/src/main/resources/email/email-activate-user.html index 331b521..132a8a7 100644 --- a/src/main/resources/email/email-activate-user.html +++ b/src/main/resources/email/email-activate-user.html @@ -8,7 +8,7 @@

Hello, ${P_NAME}

Please activate your account

-

Click here

+

Click here

\ No newline at end of file diff --git a/src/test/java/com/iamnbty/training/backend/TestUserService.java b/src/test/java/com/iamnbty/training/backend/TestUserService.java index ff418b7..b38320b 100644 --- a/src/test/java/com/iamnbty/training/backend/TestUserService.java +++ b/src/test/java/com/iamnbty/training/backend/TestUserService.java @@ -8,10 +8,12 @@ import com.iamnbty.training.backend.service.AddressService; import com.iamnbty.training.backend.service.SocialService; import com.iamnbty.training.backend.service.UserService; +import com.iamnbty.training.backend.util.SecurityUtil; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import java.util.Date; import java.util.List; import java.util.Optional; @@ -31,10 +33,13 @@ class TestUserService { @Order(1) @Test void testCreate() throws BaseException { + String token = SecurityUtil.generateToken(); User user = userService.create( TestCreateData.email, TestCreateData.password, - TestCreateData.name + TestCreateData.name, + token, + new Date() ); // check not null