diff --git a/build.gradle b/build.gradle index 086613b..d7a34ff 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation 'org.postgresql:postgresql' compile "io.springfox:springfox-swagger2:2.9.2" compile group: 'io.springfox', name: 'springfox-swagger-ui', version: '2.9.2' + compile "org.springframework.boot:spring-boot-configuration-processor" implementation('org.springframework.boot:spring-boot-starter-web') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-json' diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/EmailConfigurationService.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/EmailConfigurationService.java new file mode 100644 index 0000000..424d2d3 --- /dev/null +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/EmailConfigurationService.java @@ -0,0 +1,90 @@ +package ch.usi.inf.sa4.sanmarinoes.smarthut.config; + +import javax.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Class to interface with `email.*` properties in application.properties. This properties are used + * for generating the email to send on password reset or registration + * + * @see ch.usi.inf.sa4.sanmarinoes.smarthut.controller.UserAccountController + */ +@Component +@EnableConfigurationProperties +@ConfigurationProperties(prefix = "email") +public class EmailConfigurationService { + + /** The email subject for a registration email */ + @NotNull private String registrationSubject; + + /** The text in the email body preceding the confirmation URL for a registration email */ + @NotNull private String registration; + + /** + * The URL to follow for registration email confirmation. Has to end with the start of a query + * parameter + */ + @NotNull private String registrationPath; + + /** The email subject for a reset password email */ + @NotNull private String resetPasswordSubject; + + /** The text in the email body preceding the confirmation URL for a reset password email */ + @NotNull private String resetPassword; + + /** + * The URL to follow for password reset email confirmation. Has to end with the start of a query + * parameter + */ + @NotNull private String resetPasswordPath; + + public String getRegistrationSubject() { + return registrationSubject; + } + + public void setRegistrationSubject(String registrationSubject) { + this.registrationSubject = registrationSubject; + } + + public String getRegistration() { + return registration; + } + + public void setRegistration(String registration) { + this.registration = registration; + } + + public String getRegistrationPath() { + return registrationPath; + } + + public void setRegistrationPath(String registrationPath) { + this.registrationPath = registrationPath; + } + + public String getResetPasswordSubject() { + return resetPasswordSubject; + } + + public void setResetPasswordSubject(String resetPasswordSubject) { + this.resetPasswordSubject = resetPasswordSubject; + } + + public String getResetPassword() { + return resetPassword; + } + + public void setResetPassword(String resetPassword) { + this.resetPassword = resetPassword; + } + + public String getResetPasswordPath() { + return resetPasswordPath; + } + + public void setResetPasswordPath(String resetPasswordPath) { + this.resetPasswordPath = resetPasswordPath; + } +} diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/WebSecurityConfig.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/WebSecurityConfig.java index e38d0df..253998d 100644 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/WebSecurityConfig.java +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/WebSecurityConfig.java @@ -52,10 +52,11 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { .authorizeRequests() .antMatchers( "/auth/login", - "/auth/register", "/swagger-ui.html", "/register", "/register/confirm-account", + "/register/init-reset-password", + "/register/reset-password", "/v2/api-docs", "/webjars/**", "/swagger-resources/**", diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/controller/UserAccountController.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/controller/UserAccountController.java index e238f16..ebf354f 100644 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/controller/UserAccountController.java +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/controller/UserAccountController.java @@ -1,9 +1,13 @@ package ch.usi.inf.sa4.sanmarinoes.smarthut.controller; +import ch.usi.inf.sa4.sanmarinoes.smarthut.config.EmailConfigurationService; +import ch.usi.inf.sa4.sanmarinoes.smarthut.dto.InitPasswordResetRequest; import ch.usi.inf.sa4.sanmarinoes.smarthut.dto.OkResponse; +import ch.usi.inf.sa4.sanmarinoes.smarthut.dto.PasswordResetRequest; import ch.usi.inf.sa4.sanmarinoes.smarthut.dto.UserRegistrationRequest; import ch.usi.inf.sa4.sanmarinoes.smarthut.error.DuplicateRegistrationException; import ch.usi.inf.sa4.sanmarinoes.smarthut.error.EmailTokenNotFoundException; +import ch.usi.inf.sa4.sanmarinoes.smarthut.error.UserNotFoundException; import ch.usi.inf.sa4.sanmarinoes.smarthut.models.ConfirmationToken; import ch.usi.inf.sa4.sanmarinoes.smarthut.models.ConfirmationTokenRepository; import ch.usi.inf.sa4.sanmarinoes.smarthut.models.User; @@ -11,30 +15,66 @@ import ch.usi.inf.sa4.sanmarinoes.smarthut.models.UserRepository; import ch.usi.inf.sa4.sanmarinoes.smarthut.service.EmailSenderService; import javax.validation.Valid; import javax.validation.constraints.NotNull; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.mail.SimpleMailMessage; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +/** Unauthenticated set of endpoints to handle registration and password reset */ @RestController @EnableAutoConfiguration @RequestMapping("/register") public class UserAccountController { - @Autowired private UserRepository userRepository; + private final UserRepository userRepository; - @Autowired private ConfirmationTokenRepository confirmationTokenRepository; + private final ConfirmationTokenRepository confirmationTokenRepository; - @Autowired private EmailSenderService emailSenderService; + private final EmailSenderService emailSenderService; - @Autowired private BCryptPasswordEncoder encoder; + private final BCryptPasswordEncoder encoder; + private final EmailConfigurationService emailConfig; + + public UserAccountController( + UserRepository userRepository, + ConfirmationTokenRepository confirmationTokenRepository, + EmailSenderService emailSenderService, + BCryptPasswordEncoder encoder, + EmailConfigurationService emailConfig) { + this.userRepository = userRepository; + this.confirmationTokenRepository = confirmationTokenRepository; + this.emailSenderService = emailSenderService; + this.encoder = encoder; + this.emailConfig = emailConfig; + } + + private void sendEmail(String email, ConfirmationToken token, boolean isRegistration) { + SimpleMailMessage mailMessage = new SimpleMailMessage(); + mailMessage.setTo(email); + mailMessage.setSubject( + isRegistration + ? emailConfig.getRegistrationSubject() + : emailConfig.getResetPasswordSubject()); + mailMessage.setFrom("smarthut.sm@gmail.com"); + mailMessage.setText( + (isRegistration ? emailConfig.getRegistration() : emailConfig.getResetPassword()) + + " " + + (isRegistration + ? emailConfig.getRegistrationPath() + : emailConfig.getResetPasswordPath()) + + token.getConfirmationToken()); + + emailSenderService.sendEmail(mailMessage); + } + + /** + * Unauthenticated endpoint to call to send a password reset email + * + * @param registrationData registration data of the new user + * @return success + * @throws DuplicateRegistrationException if a user exists with same email or username + */ @PostMapping public OkResponse registerUser(@Valid @RequestBody UserRegistrationRequest registrationData) throws DuplicateRegistrationException { @@ -60,35 +100,101 @@ public class UserAccountController { toSave.setEmail(registrationData.getEmail()); userRepository.save(toSave); - ConfirmationToken confirmationToken = new ConfirmationToken(toSave); + ConfirmationToken token; + do { + token = new ConfirmationToken(toSave); + } while (confirmationTokenRepository.findByConfirmationToken( + token.getConfirmationToken()) + != null); - confirmationTokenRepository.save(confirmationToken); + confirmationTokenRepository.save(token); - SimpleMailMessage mailMessage = new SimpleMailMessage(); - mailMessage.setTo(registrationData.getEmail()); - mailMessage.setSubject("Complete Registration!"); - mailMessage.setFrom("smarthut.sm@gmail.com"); - mailMessage.setText( - "To confirm your account, please click here : " - + "http://localhost:8080/register/confirm-account?token=" - + confirmationToken.getConfirmationToken()); - - emailSenderService.sendEmail(mailMessage); + sendEmail(toSave.getEmail(), token, true); return new OkResponse(); } } + /** + * Unauthenticated endpoint to call to send a password reset email + * + * @param resetRequest a JSON object containing the email of the user to reset + * @return success + * @throws UserNotFoundException if given email does not belong to any user + */ + @PostMapping("/init-reset-password") + public OkResponse initResetPassword(@Valid @RequestBody InitPasswordResetRequest resetRequest) + throws UserNotFoundException { + final User toReset = userRepository.findByEmailIgnoreCase(resetRequest.getEmail()); + + // Check if an User with the same email already exists + if (toReset == null) { + throw new UserNotFoundException(); + } + + ConfirmationToken token; + do { + token = new ConfirmationToken(toReset); + token.setResetPassword(true); + } while (confirmationTokenRepository.findByConfirmationToken(token.getConfirmationToken()) + != null); + + // Delete existing email password reset tokens + confirmationTokenRepository.deleteByUserAndResetPassword(toReset, true); + + // Save new token + confirmationTokenRepository.save(token); + + sendEmail(toReset.getEmail(), token, false); + + return new OkResponse(); + } + + /** + * Unauthenticated endpoint to call with token sent by email to reset password + * + * @param resetRequest the token given via email and the new password + * @return success + * @throws EmailTokenNotFoundException if given token is not a valid token for password reset + */ + @PutMapping("/reset-password") + public OkResponse resetPassword(@Valid @RequestBody PasswordResetRequest resetRequest) + throws EmailTokenNotFoundException { + final ConfirmationToken token = + confirmationTokenRepository.findByConfirmationToken( + resetRequest.getConfirmationToken()); + + if (token == null || !token.getResetPassword()) { + throw new EmailTokenNotFoundException(); + } + + final User user = token.getUser(); + user.setPassword(encoder.encode(resetRequest.getPassword())); + userRepository.save(user); + + // Delete token to prevent further password changes + confirmationTokenRepository.delete(token); + + return new OkResponse(); + } + + /** + * Unauthenticated endpoint to call with token sent by email to enable user + * + * @param confirmationToken the token given via email + * @return success + * @throws EmailTokenNotFoundException if given token is not a valid token for email + * confirmation + */ @GetMapping(value = "/confirm-account") public OkResponse confirmUserAccount(@RequestParam("token") @NotNull String confirmationToken) throws EmailTokenNotFoundException { final ConfirmationToken token = confirmationTokenRepository.findByConfirmationToken(confirmationToken); - if (token != null) { - final User user = userRepository.findByEmailIgnoreCase(token.getUser().getEmail()); - user.setEnabled(true); - userRepository.save(user); + if (token != null && !token.getResetPassword()) { + token.getUser().setEnabled(true); + userRepository.save(token.getUser()); // TODO: redirect to frontend return new OkResponse(); } else { diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/dto/InitPasswordResetRequest.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/dto/InitPasswordResetRequest.java new file mode 100644 index 0000000..d82c4f0 --- /dev/null +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/dto/InitPasswordResetRequest.java @@ -0,0 +1,25 @@ +package ch.usi.inf.sa4.sanmarinoes.smarthut.dto; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Pattern; + +/** DTO for password reset request */ +public class InitPasswordResetRequest { + /** + * The user's email (validated according to criteria used in >input type="email"<> + * , technically not RFC 5322 compliant + */ + @NotEmpty(message = "Please provide an email") + @Email(message = "Please provide a valid email address") + @Pattern(regexp = ".+@.+\\..+", message = "Please provide a valid email address") + private String email; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/dto/PasswordResetRequest.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/dto/PasswordResetRequest.java new file mode 100644 index 0000000..bf5bccf --- /dev/null +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/dto/PasswordResetRequest.java @@ -0,0 +1,34 @@ +package ch.usi.inf.sa4.sanmarinoes.smarthut.dto; + +import javax.validation.constraints.*; + +/** DTO for password reset request */ +public class PasswordResetRequest { + + @NotNull private String confirmationToken; + + /** A properly salted way to store the password */ + @NotNull + @NotEmpty(message = "Please provide a password") + @Size( + min = 6, + max = 255, + message = "Your password should be at least 6 characters long and up to 255 chars long") + private String password; + + public String getConfirmationToken() { + return confirmationToken; + } + + public void setConfirmationToken(String confirmationToken) { + this.confirmationToken = confirmationToken; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/models/ConfirmationToken.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/models/ConfirmationToken.java index f6c86a0..d324724 100644 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/models/ConfirmationToken.java +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/models/ConfirmationToken.java @@ -21,7 +21,7 @@ public class ConfirmationToken { @Column(name = "id", updatable = false, nullable = false) private Long id; - @Column(name = "confirmation_token") + @Column(name = "confirmation_token", unique = true) private String confirmationToken; @Temporal(TemporalType.TIMESTAMP) @@ -31,10 +31,14 @@ public class ConfirmationToken { @JoinColumn(nullable = false, name = "user_id") private User user; + @Column(nullable = false) + private Boolean resetPassword; + public ConfirmationToken(User user) { this.user = user; createdDate = new Date(); confirmationToken = UUID.randomUUID().toString(); + resetPassword = false; } /** Constructor for hibernate reflective stuff things whatever */ @@ -71,4 +75,12 @@ public class ConfirmationToken { public void setUser(User user) { this.user = user; } + + public Boolean getResetPassword() { + return resetPassword; + } + + public void setResetPassword(Boolean resetPassword) { + this.resetPassword = resetPassword; + } } diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/models/ConfirmationTokenRepository.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/models/ConfirmationTokenRepository.java index 4bc18ce..9bf3791 100644 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/models/ConfirmationTokenRepository.java +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/models/ConfirmationTokenRepository.java @@ -1,7 +1,11 @@ package ch.usi.inf.sa4.sanmarinoes.smarthut.models; +import javax.transaction.Transactional; import org.springframework.data.repository.CrudRepository; public interface ConfirmationTokenRepository extends CrudRepository { ConfirmationToken findByConfirmationToken(String confirmationToken); + + @Transactional + void deleteByUserAndResetPassword(User user, boolean resetPassword); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9bfe2a7..14c91cd 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -22,4 +22,12 @@ spring.mail.properties.mail.smtp.starttls.required=true spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.connectiontimeout=5000 spring.mail.properties.mail.smtp.timeout=5000 -spring.mail.properties.mail.smtp.writetimeout=5000 \ No newline at end of file +spring.mail.properties.mail.smtp.writetimeout=5000 + +email.registrationsubject=Complete your SmartHut.sm registration +email.registration=To confirm your registration, please click here: +email.registraionpath=http://localhost:8080/register/confirm-account?token= + +email.resetpasswordsubject=SmartHut.sm password reset +email.resetpassword=To reset your password, please click here: +email.resetpasswordpath=http://localhost:3000/password-reset?token= \ No newline at end of file