diff --git a/pom.xml b/pom.xml index 5c76030..49f030f 100644 --- a/pom.xml +++ b/pom.xml @@ -142,6 +142,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-mail + io.nflow nflow-rest-api-spring-web diff --git a/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java b/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java index 930c05c..ace5062 100644 --- a/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java +++ b/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java @@ -28,7 +28,6 @@ public class AuthController { private final WorkflowInstanceFactory workflowInstanceFactory; private final WorkflowUtils workflowUtils; - @PostMapping("/register") public ResponseEntity register(@Valid @RequestBody RegisterRequest request) throws ExecutionException, InterruptedException { @@ -42,8 +41,8 @@ public class AuthController { CompletableFuture future = workflowUtils .waitForState(workflowId, "sendOtp", 30000, 1000); - future.get(); - return ResponseEntity.ok(Utils.toJson(future)); + Object result = future.get(); + return ResponseEntity.ok(Utils.toJson(result)); } @PostMapping("/login") diff --git a/src/main/java/zw/qantra/tm/domain/dtos/EmailRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/EmailRequest.java new file mode 100644 index 0000000..33ef050 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/EmailRequest.java @@ -0,0 +1,20 @@ +package zw.qantra.tm.domain.dtos; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EmailRequest { + private String to; + private List cc; + private String subject; + private String body; + private boolean isHtml; +} diff --git a/src/main/java/zw/qantra/tm/domain/enums/Status.java b/src/main/java/zw/qantra/tm/domain/enums/Status.java index 2709fc7..c262885 100755 --- a/src/main/java/zw/qantra/tm/domain/enums/Status.java +++ b/src/main/java/zw/qantra/tm/domain/enums/Status.java @@ -8,5 +8,7 @@ public enum Status { REVERSED, AUTHORIZED, REJECTED, - PROCESSING + PROCESSING, + COMPLETE, + ACTIVE } diff --git a/src/main/java/zw/qantra/tm/domain/models/Otp.java b/src/main/java/zw/qantra/tm/domain/models/Otp.java new file mode 100644 index 0000000..a721054 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/models/Otp.java @@ -0,0 +1,22 @@ +package zw.qantra.tm.domain.models; + + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.*; +import zw.qantra.tm.domain.enums.Status; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Otp extends BaseEntity{ + private String username; + private String otp; + @Enumerated(EnumType.STRING) + private Status otpStatus; + private String otpType; +} diff --git a/src/main/java/zw/qantra/tm/domain/repositories/OtpRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/OtpRepository.java new file mode 100644 index 0000000..c19474c --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/repositories/OtpRepository.java @@ -0,0 +1,14 @@ +package zw.qantra.tm.domain.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import zw.qantra.tm.domain.models.Otp; + +import java.util.Optional; + +@Repository +public interface OtpRepository extends JpaRepository { + Optional findByUsernameAndOtpType(String username, String otpType); + Optional findByUsernameAndOtpTypeAndOtp(String username, String otpType, String otp); + void deleteByUsernameAndOtpType(String username, String otpType); +} diff --git a/src/main/java/zw/qantra/tm/domain/services/EmailService.java b/src/main/java/zw/qantra/tm/domain/services/EmailService.java new file mode 100644 index 0000000..bf6c387 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/EmailService.java @@ -0,0 +1,46 @@ +package zw.qantra.tm.domain.services; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import zw.qantra.tm.domain.dtos.EmailRequest; + +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + + /** + * Sends an email based on the provided EmailRequest + * @param emailRequest the email request containing recipient, subject, and body + * @return true if the email was sent successfully, false otherwise + */ + public boolean sendEmail(EmailRequest emailRequest) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + + helper.setTo(emailRequest.getTo()); + if (emailRequest.getCc() != null && !emailRequest.getCc().isEmpty()) { + helper.setCc(emailRequest.getCc().toArray(new String[0])); + } + helper.setSubject(emailRequest.getSubject()); + helper.setText(emailRequest.getBody(), emailRequest.isHtml()); + + mailSender.send(message); + log.info("Email sent successfully to {}", emailRequest.getTo()); + return true; + } catch (MessagingException e) { + log.error("Failed to send email to {}: {}", emailRequest.getTo(), e.getMessage(), e); + return false; + } + } +} diff --git a/src/main/java/zw/qantra/tm/domain/services/OtpService.java b/src/main/java/zw/qantra/tm/domain/services/OtpService.java new file mode 100644 index 0000000..2551a9b --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/OtpService.java @@ -0,0 +1,67 @@ +package zw.qantra.tm.domain.services; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.models.Otp; +import zw.qantra.tm.domain.repositories.OtpRepository; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class OtpService { + + private static final int OTP_LENGTH = 6; + private static final String NUMBERS = "0123456789"; + private static final SecureRandom random = new SecureRandom(); + + private final OtpRepository otpRepository; + + public Otp generateOtp(String username, String otpType) { + // Delete any existing OTP for this username and type + otpRepository.deleteByUsernameAndOtpType(username, otpType); + + String otpCode = generateRandomOtp(); + + Otp otp = Otp.builder() + .username(username) + .otp(otpCode) + .otpType(otpType) + .otpStatus(Status.ACTIVE) + .build(); + + return otpRepository.save(otp); + } + + public boolean verifyOtp(String username, String otpCode, String otpType) { + Optional otpOpt = otpRepository.findByUsernameAndOtpTypeAndOtp(username, otpType, otpCode); + + if (otpOpt.isEmpty()) { + return false; + } + + Otp otp = otpOpt.get(); + + // Mark OTP as used + otp.setOtpStatus(Status.COMPLETE); + otpRepository.save(otp); + + return true; + } + + public boolean isOtpValid(String username, String otpType) { + Optional otpOpt = otpRepository.findByUsernameAndOtpType(username, otpType); + return otpOpt.isPresent() && otpOpt.get().getOtpStatus() == Status.ACTIVE; + } + + private String generateRandomOtp() { + StringBuilder otp = new StringBuilder(OTP_LENGTH); + for (int i = 0; i < OTP_LENGTH; i++) { + otp.append(NUMBERS.charAt(random.nextInt(NUMBERS.length()))); + } + return otp.toString(); + } +} diff --git a/src/main/java/zw/qantra/tm/domain/workflows/RegistrationWorkflow.java b/src/main/java/zw/qantra/tm/domain/workflows/RegistrationWorkflow.java index 41f6910..f6ee5e9 100644 --- a/src/main/java/zw/qantra/tm/domain/workflows/RegistrationWorkflow.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/RegistrationWorkflow.java @@ -1,12 +1,17 @@ package zw.qantra.tm.domain.workflows; import io.nflow.engine.workflow.definition.WorkflowStateType; +import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import io.nflow.engine.workflow.definition.NextAction; import io.nflow.engine.workflow.definition.StateExecution; import io.nflow.engine.workflow.definition.WorkflowDefinition; +import zw.qantra.tm.domain.dtos.EmailRequest; +import zw.qantra.tm.domain.models.Otp; +import zw.qantra.tm.domain.services.EmailService; +import zw.qantra.tm.domain.services.OtpService; import zw.qantra.tm.domain.services.UserService; import io.nflow.engine.workflow.curated.State; import zw.qantra.tm.domain.dtos.AuthResponse; @@ -18,6 +23,7 @@ import java.util.Set; @Component public class RegistrationWorkflow extends WorkflowDefinition { + Logger log = org.slf4j.LoggerFactory.getLogger(RegistrationWorkflow.class); public static final String TYPE = "registrationWorkflow"; @@ -35,6 +41,10 @@ public class RegistrationWorkflow extends WorkflowDefinition { private UserService userService; @Autowired private Validator validator; + @Autowired + private OtpService otpService; + @Autowired + private EmailService emailService; public RegistrationWorkflow() { super(TYPE, BEGIN, FAILED); @@ -72,14 +82,50 @@ public class RegistrationWorkflow extends WorkflowDefinition { } // todo: add this to the appropriate action - // AuthResponse response = userService.register(payload); execution.setVariable("metadata", payload); return NextAction.moveToState(SEND_OTP, "Initial registration success for: " + payload.getUsername()); } public void sendOtp(StateExecution execution) { - RegisterRequest response = execution.getVariable("metadata", RegisterRequest.class); + try { + RegisterRequest registerRequest = execution.getVariable("metadata", RegisterRequest.class); + String username = registerRequest.getUsername(); + String email = registerRequest.getEmail(); + + // Generate OTP + Otp otp = otpService.generateOtp(username, "REGISTRATION"); + + // Create email content + String subject = "Your Verification Code"; + String body = String.format( + "" + + "

Email Verification

" + + "

Hello %s,

" + + "

Your verification code is: %s

" + + "

This code will expire in 10 minutes.

" + + "

If you didn't request this code, please ignore this email.

" + + "", + username, otp.getOtp() + ); + + // Send email + EmailRequest emailRequest = EmailRequest.builder() + .to(email) + .subject(subject) + .body(body) + .isHtml(true) + .build(); + + boolean emailSent = emailService.sendEmail(emailRequest); + + if (!emailSent) { + log.error("Failed to send OTP email to: {}", email); + } + + } catch (Exception e) { + log.error("Error sending OTP: {}", e.getMessage(), e); + } } public NextAction verifyOtp(StateExecution execution) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bf9264a..0799893 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -47,3 +47,15 @@ jwt.secret=your-super-secret-jwt-key-here-make-it-very-long-and-secure-in-produc jwt.expiration=86400000 logging.level.root=WARN + +# Email Configuration +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=vusakhoza@gmail.com +spring.mail.password=Stikbanch@14 +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.starttls.required=true +spring.mail.properties.mail.smtp.connectiontimeout=5000 +spring.mail.properties.mail.smtp.timeout=5000 +spring.mail.properties.mail.smtp.writetimeout=5000