diff --git a/backend/src/main/java/com/morris/quizly/configurations/SecurityConfiguration.java b/backend/src/main/java/com/morris/quizly/configurations/SecurityConfiguration.java index e5446f1..eafaacc 100644 --- a/backend/src/main/java/com/morris/quizly/configurations/SecurityConfiguration.java +++ b/backend/src/main/java/com/morris/quizly/configurations/SecurityConfiguration.java @@ -37,6 +37,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(authorize -> authorize .requestMatchers( "/api/auth/**", + "/api/subscriptions/**", "/api/quiz-generation/**", "/api/quiz-retrieval/**", "/login", diff --git a/backend/src/main/java/com/morris/quizly/controllers/AuthController.java b/backend/src/main/java/com/morris/quizly/controllers/AuthController.java index cda3dac..06f3f8e 100644 --- a/backend/src/main/java/com/morris/quizly/controllers/AuthController.java +++ b/backend/src/main/java/com/morris/quizly/controllers/AuthController.java @@ -1,9 +1,7 @@ package com.morris.quizly.controllers; -import com.morris.quizly.models.security.AuthRequest; -import com.morris.quizly.models.security.JwtTokenProvider; -import com.morris.quizly.models.security.Roles; -import com.morris.quizly.models.security.SignupRequest; +import com.morris.quizly.models.security.*; +import com.morris.quizly.services.NotificationService; import com.morris.quizly.services.QuizlyUserDetailsService; import com.morris.quizly.utils.FileUtil; import org.slf4j.Logger; @@ -17,7 +15,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; @@ -25,6 +22,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; @RestController @RequestMapping("/api/auth") @@ -35,6 +33,7 @@ public class AuthController { private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final QuizlyUserDetailsService quizlyUserDetailsService; + private final NotificationService notificationService; private static final String USER_DETAILS = "userDetails"; private static final String ACCESS_TOKEN = "accessToken"; @@ -53,11 +52,13 @@ public class AuthController { @Autowired public AuthController(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, - JwtTokenProvider jwtTokenProvider, QuizlyUserDetailsService quizlyDetailsService) { + JwtTokenProvider jwtTokenProvider, QuizlyUserDetailsService quizlyDetailsService, + NotificationService notificationService) { this.authenticationManager = authenticationManager; this.passwordEncoder = passwordEncoder; this.jwtTokenProvider = jwtTokenProvider; this.quizlyUserDetailsService = quizlyDetailsService; + this.notificationService = notificationService; } @PostMapping("/login") @@ -168,6 +169,7 @@ public ResponseEntity signup(@ModelAttribute SignupRequest signupRequest) { LOGGER.error("Image is null: {}", e.getMessage()); } LOGGER.info(imageBase64EncodedStr); + String signupToken = UUID.randomUUID().toString(); // make this secure com.morris.quizly.models.security.UserDetails userDetails = com.morris.quizly.models.security.UserDetails.builder() .firstName(signupRequest.getFirstName()) .lastName(signupRequest.getLastName()) @@ -179,12 +181,14 @@ public ResponseEntity signup(@ModelAttribute SignupRequest signupRequest) { .accountNonLocked(true) .accountNonExpired(true) .credentialsNonExpired(true) - .enabled(true) + .enabled(false) + .signupToken(signupToken) .build(); try { UserDetails userDetailsResult = quizlyUserDetailsService.save(userDetails); - if (userDetailsResult.isEnabled()) { + if (userDetailsResult.isAccountNonLocked()) { + notificationService.sendSignupConfirmationEmailAndLink(userDetailsResult, signupToken); return ResponseEntity.ok() .body(SIGNUP_SUCCESS); } diff --git a/backend/src/main/java/com/morris/quizly/controllers/SubscriptionController.java b/backend/src/main/java/com/morris/quizly/controllers/SubscriptionController.java new file mode 100644 index 0000000..42715d7 --- /dev/null +++ b/backend/src/main/java/com/morris/quizly/controllers/SubscriptionController.java @@ -0,0 +1,68 @@ +package com.morris.quizly.controllers; + +import com.morris.quizly.models.security.UserDetails; +import com.morris.quizly.services.QuizlyUserDetailsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/subscriptions") +public class SubscriptionController { + + private final QuizlyUserDetailsService userService; + + @Autowired + public SubscriptionController(QuizlyUserDetailsService userService) { + this.userService = userService; + } + + @GetMapping("/confirm-signup") + public ResponseEntity confirmSignup(@RequestParam("token") String token) { + UserDetails user = userService.findBySignupToken(token); + if (null == user) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid token"); + } + userService.updateEnabledStatus(user, true); + + String htmlResponse = "" + + "" + + "" + + "" + + "" + + "
" + + "" + + "

Thanks for confirming signup with Quizly, " + user.getFirstName() + "!

" + + "
" + + "" + + "" + + ""; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.TEXT_HTML); + return ResponseEntity.ok().headers(headers).body(htmlResponse); + } +} diff --git a/backend/src/main/java/com/morris/quizly/models/security/UserDetails.java b/backend/src/main/java/com/morris/quizly/models/security/UserDetails.java index 166bd6e..ae666a6 100644 --- a/backend/src/main/java/com/morris/quizly/models/security/UserDetails.java +++ b/backend/src/main/java/com/morris/quizly/models/security/UserDetails.java @@ -43,6 +43,7 @@ public class UserDetails implements org.springframework.security.core.userdetail private boolean credentialsNonExpired; private boolean enabled; private String lockedReason; + private String signupToken; private int flagCount; List flags; diff --git a/backend/src/main/java/com/morris/quizly/repositories/UserRepository.java b/backend/src/main/java/com/morris/quizly/repositories/UserRepository.java index e432cc3..da4ec64 100644 --- a/backend/src/main/java/com/morris/quizly/repositories/UserRepository.java +++ b/backend/src/main/java/com/morris/quizly/repositories/UserRepository.java @@ -20,6 +20,15 @@ public interface UserRepository extends MongoRepository { */ Optional findByUsername(String username); + /** + * Find user by signup token. Signup tokens are used to confirm application subscriptions. + * + * @param token {@link String} signup token + * + * @return {@link UserDetails} + */ + Optional findBySignupToken(String token); + /** * Determines if an account is non-locked. * diff --git a/backend/src/main/java/com/morris/quizly/services/EmailService.java b/backend/src/main/java/com/morris/quizly/services/EmailService.java index 052dc50..08f8ecb 100644 --- a/backend/src/main/java/com/morris/quizly/services/EmailService.java +++ b/backend/src/main/java/com/morris/quizly/services/EmailService.java @@ -1,5 +1,13 @@ package com.morris.quizly.services; public interface EmailService { + + /** + * Sends email. + * + * @param to {@link String} email recipient + * @param subject {@link String} Subject + * @param body {@link String} email content + */ void sendEmail(String to, String subject, String body); } diff --git a/backend/src/main/java/com/morris/quizly/services/NotificationService.java b/backend/src/main/java/com/morris/quizly/services/NotificationService.java index 52bb07c..d5e37aa 100644 --- a/backend/src/main/java/com/morris/quizly/services/NotificationService.java +++ b/backend/src/main/java/com/morris/quizly/services/NotificationService.java @@ -1,5 +1,7 @@ package com.morris.quizly.services; +import com.morris.quizly.models.security.UserDetails; + public interface NotificationService { /** @@ -11,5 +13,11 @@ public interface NotificationService { */ boolean notifyAdmin(String message); - boolean subscribeUserToAppUserTopic(String userEmailAddress); + /** + * Sends signup confirmation email to user. + * + * @param user {@link UserDetails} + * @param token {@link String} confirmation token + */ + void sendSignupConfirmationEmailAndLink(UserDetails user, String token); } diff --git a/backend/src/main/java/com/morris/quizly/services/QuizlyUserDetailsService.java b/backend/src/main/java/com/morris/quizly/services/QuizlyUserDetailsService.java index 552b35b..2aad1be 100644 --- a/backend/src/main/java/com/morris/quizly/services/QuizlyUserDetailsService.java +++ b/backend/src/main/java/com/morris/quizly/services/QuizlyUserDetailsService.java @@ -33,4 +33,20 @@ public interface QuizlyUserDetailsService extends UserDetailsService { * @return boolean */ boolean isUserNameInUse(String username); + + /** + * Find user by signup token. + * + * @param token {@link String} signup token + * @return {@link UserDetails} + */ + UserDetails findBySignupToken(String token); + + /** + * Modifies user enabled status. + * + * @param user {@link UserDetails} user + * @param enabled boolean + */ + void updateEnabledStatus(UserDetails user, boolean enabled); } diff --git a/backend/src/main/java/com/morris/quizly/services/impl/EmailServiceImpl.java b/backend/src/main/java/com/morris/quizly/services/impl/EmailServiceImpl.java index 2902cd9..8d7c730 100644 --- a/backend/src/main/java/com/morris/quizly/services/impl/EmailServiceImpl.java +++ b/backend/src/main/java/com/morris/quizly/services/impl/EmailServiceImpl.java @@ -1,50 +1,63 @@ package com.morris.quizly.services.impl; import com.morris.quizly.services.EmailService; +import com.sendgrid.Method; +import com.sendgrid.Request; +import com.sendgrid.Response; +import com.sendgrid.SendGrid; +import com.sendgrid.helpers.mail.Mail; +import com.sendgrid.helpers.mail.objects.ClickTrackingSetting; +import com.sendgrid.helpers.mail.objects.Content; +import com.sendgrid.helpers.mail.objects.Email; +import com.sendgrid.helpers.mail.objects.TrackingSettings; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import javax.mail.*; -import javax.mail.internet.InternetAddress; -import javax.mail.internet.MimeMessage; -import java.util.Properties; +import java.io.IOException; @Service public class EmailServiceImpl implements EmailService { + private static final Logger LOGGER = LoggerFactory.getLogger(EmailServiceImpl.class); - @Value("$gmail.username") - private String admin; - - @Value("${gmail.password}") - private String password; + @Value("${sendgrid.signup.confirmation.api}") + private String key; @Override public void sendEmail(String to, String subject, String body) { - Properties props = new Properties(); - props.put("mail.smtp.auth", "true"); - props.put("mail.smtp.starttls.enable", "true"); - props.put("mail.smtp.host", "smtp.gmail.com"); - props.put("mail.smtp.port", "587"); - - Session session = Session.getInstance(props, - new javax.mail.Authenticator() { - protected PasswordAuthentication getPasswordAuthentication() { - return new PasswordAuthentication(admin, password); - } - }); + Mail mail = getMail(to, subject, body); + SendGrid sg = new SendGrid(key); + Request request = new Request(); try { - Message message = new MimeMessage(session); - message.setFrom(new InternetAddress(admin)); - message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to)); - message.setSubject(subject); - message.setText(body); - - Transport.send(message); - - System.out.println("Email sent successfully"); - } catch (MessagingException e) { - throw new RuntimeException(e); + request.setMethod(Method.POST); + request.setEndpoint("mail/send"); + request.setBody(mail.build()); + Response response = sg.api(request); + System.out.println(response.getStatusCode()); + System.out.println(response.getBody()); + System.out.println(response.getHeaders()); + } catch (IOException e) { + LOGGER.error("Error sending email: {}", e.getMessage()); } } + + @NotNull + private Mail getMail(String to, String subject, String body) { + Email from = new Email("ai.quizly@gmail.com"); + Email toEmail = new Email(to); + Content content = new Content("text/plain", body); + Mail mail = new Mail(from, subject, toEmail, content); + + // Disable click tracking + TrackingSettings trackingSettings = new TrackingSettings(); + ClickTrackingSetting clickTrackingSetting = new ClickTrackingSetting(); + clickTrackingSetting.setEnable(false); + clickTrackingSetting.setEnableText(false); + trackingSettings.setClickTrackingSetting(clickTrackingSetting); + mail.setTrackingSettings(trackingSettings); + return mail; + } } diff --git a/backend/src/main/java/com/morris/quizly/services/impl/NotificationServiceImpl.java b/backend/src/main/java/com/morris/quizly/services/impl/NotificationServiceImpl.java index da90b87..febdb22 100644 --- a/backend/src/main/java/com/morris/quizly/services/impl/NotificationServiceImpl.java +++ b/backend/src/main/java/com/morris/quizly/services/impl/NotificationServiceImpl.java @@ -4,8 +4,7 @@ import com.amazonaws.services.sns.AmazonSNSClientBuilder; import com.amazonaws.services.sns.model.PublishRequest; import com.amazonaws.services.sns.model.PublishResult; -import com.amazonaws.services.sns.model.SubscribeRequest; -import com.amazonaws.services.sns.model.SubscribeResult; +import com.morris.quizly.models.security.UserDetails; import com.morris.quizly.services.EmailService; import com.morris.quizly.services.NotificationService; import org.slf4j.Logger; @@ -20,12 +19,11 @@ public class NotificationServiceImpl implements NotificationService { @Value("${sns.systemsFlaggingTopicArn}") private String snsSystemsFlaggingTopicArn; - @Value("${sns.applicationUserTopicArn}") - private String snsApplicationUserTopicArn; - private final AmazonSNS amazonSNSClient; private final EmailService emailService; + private static final String DEV_CONFIRMATION_LINK = "http://localhost:8081/api/subscriptions/confirm-signup?token="; + public NotificationServiceImpl(EmailService emailService) { this.amazonSNSClient = AmazonSNSClientBuilder.defaultClient(); this.emailService = emailService; @@ -39,20 +37,20 @@ public boolean notifyAdmin(String message) { } @Override - public boolean subscribeUserToAppUserTopic(String userEmailAddress) { + public void sendSignupConfirmationEmailAndLink(UserDetails user, String token) { try { - SubscribeRequest subscribeRequest = new SubscribeRequest() - .withTopicArn(snsApplicationUserTopicArn) - .withProtocol("email") - .withEndpoint(userEmailAddress); - SubscribeResult subscribeResult = amazonSNSClient.subscribe(subscribeRequest); - sendCustomConfirmationEmail(userEmailAddress, subscribeResult.getSubscriptionArn()); - return true; + sendCustomConfirmationEmail(user, token); } catch (Exception e) { - LOGGER.error("Error subscribing user to Application User SNS Topic: {}", e.getMessage()); - return false; + LOGGER.error("Error sending signup confirmation email: {}", e.getMessage()); } } - private void sendCustomConfirmationEmail(String userEmail, String subscriptionArn) {} + private void sendCustomConfirmationEmail(UserDetails user, String token) { + String confirmationLink = DEV_CONFIRMATION_LINK + token; + String emailBody = String.format( + "Welcome to Quizly, %s! We're excited you've decided to join this educational journey with us.\n" + + "Please confirm your subscription by clicking the link below:\n\n" + confirmationLink, user.getFirstName() + ); + emailService.sendEmail(user.getEmailAddress(), "Quizly Signup Confirmation", emailBody); + } } diff --git a/backend/src/main/java/com/morris/quizly/services/impl/QuizlyUserDetailsServiceImpl.java b/backend/src/main/java/com/morris/quizly/services/impl/QuizlyUserDetailsServiceImpl.java index 34158c6..d9f6dce 100644 --- a/backend/src/main/java/com/morris/quizly/services/impl/QuizlyUserDetailsServiceImpl.java +++ b/backend/src/main/java/com/morris/quizly/services/impl/QuizlyUserDetailsServiceImpl.java @@ -6,6 +6,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @@ -14,10 +18,12 @@ public class QuizlyUserDetailsServiceImpl implements QuizlyUserDetailsService { private static final Logger LOGGER = LoggerFactory.getLogger(QuizlyUserDetailsServiceImpl.class); private final UserRepository userRepository; + private final MongoTemplate mongoTemplate; @Autowired - public QuizlyUserDetailsServiceImpl(UserRepository userRepository) { + public QuizlyUserDetailsServiceImpl(UserRepository userRepository, MongoTemplate mongoTemplate) { this.userRepository = userRepository; + this.mongoTemplate = mongoTemplate; } @Override @@ -26,6 +32,12 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); } + @Override + public UserDetails findBySignupToken(String token) { + return userRepository.findBySignupToken(token) + .orElseThrow(() -> new UsernameNotFoundException("token not found: " + token)); + } + @Override public UserDetails save(UserDetails userDetails) { return userRepository.save(userDetails); @@ -35,4 +47,11 @@ public UserDetails save(UserDetails userDetails) { public boolean isUserNameInUse(String userName) { return userRepository.findByUsername(userName).isPresent(); } + + @Override + public void updateEnabledStatus(UserDetails user, boolean enabled) { + Query query = new Query(Criteria.where("_id").is(user.getId())); + Update update = new Update().set("enabled", enabled); + mongoTemplate.updateFirst(query, update, UserDetails.class); + } } diff --git a/pom.xml b/pom.xml index cbc116b..df9d314 100644 --- a/pom.xml +++ b/pom.xml @@ -176,9 +176,9 @@ - com.sun.mail - javax.mail - 1.6.2 + com.sendgrid + sendgrid-java + 4.10.1