diff --git a/.gitignore b/.gitignore index c2065bc..4690f5f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out/ ### VS Code ### .vscode/ +application-secret.yml diff --git a/build.gradle b/build.gradle index 0c4c989..fc4ebfb 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,8 @@ dependencies { implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' implementation 'org.commonmark:commonmark:0.21.0' + + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/src/main/java/com/ll/spring_additional/SpringAdditionalApplication.java b/src/main/java/com/ll/spring_additional/SpringAdditionalApplication.java index 544474a..8755696 100644 --- a/src/main/java/com/ll/spring_additional/SpringAdditionalApplication.java +++ b/src/main/java/com/ll/spring_additional/SpringAdditionalApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableJpaAuditing // @EntityListeners(AuditingEntityListener.class) 가 작동하도록 허용 +@EnableAsync // 비동기 기능 활성화 public class SpringAdditionalApplication { public static void main(String[] args) { diff --git a/src/main/java/com/ll/spring_additional/base/initData/NotProd.java b/src/main/java/com/ll/spring_additional/base/initData/NotProd.java index a21a00b..ffb1c46 100644 --- a/src/main/java/com/ll/spring_additional/base/initData/NotProd.java +++ b/src/main/java/com/ll/spring_additional/base/initData/NotProd.java @@ -34,6 +34,7 @@ public void run(String... args) throws Exception { SiteUser user1 = userService.create("user1", "user1@test.com", "1234"); SiteUser user2 = userService.create("user2", "user2@test.com", "1234"); + SiteUser user3 = userService.create("puar12", "r4560798@naver.com", "1234"); for (int i = 1; i <= 300; i++) { String subject = String.format("테스트 데이터입니다:[%03d]", i); diff --git a/src/main/java/com/ll/spring_additional/boundedContext/user/Form/PWChangeForm.java b/src/main/java/com/ll/spring_additional/boundedContext/user/Form/PWChangeForm.java new file mode 100644 index 0000000..cdd12c5 --- /dev/null +++ b/src/main/java/com/ll/spring_additional/boundedContext/user/Form/PWChangeForm.java @@ -0,0 +1,20 @@ +package com.ll.spring_additional.boundedContext.user.Form; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PWChangeForm { + @NotBlank(message = "기존 비밀번호는 필수항목입니다.") + private String prePassword; + + @NotBlank(message = "새 비밀번호는 필수항목입니다.") + private String newPassword1; + + @NotBlank(message = "새 비밀번호 확인은 필수항목입니다.") + private String newPassword2; +} \ No newline at end of file diff --git a/src/main/java/com/ll/spring_additional/boundedContext/user/Form/UserPWFindForm.java b/src/main/java/com/ll/spring_additional/boundedContext/user/Form/UserPWFindForm.java new file mode 100644 index 0000000..7ff74ee --- /dev/null +++ b/src/main/java/com/ll/spring_additional/boundedContext/user/Form/UserPWFindForm.java @@ -0,0 +1,19 @@ +package com.ll.spring_additional.boundedContext.user.Form; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserPWFindForm { + @Size(min = 3, max = 25) + @NotBlank(message = "사용자ID는 필수항목입니다.") + private String username; + + @NotBlank(message = "이메일은 필수항목입니다.") + @Email + private String email; +} \ No newline at end of file diff --git a/src/main/java/com/ll/spring_additional/boundedContext/user/controller/UserController.java b/src/main/java/com/ll/spring_additional/boundedContext/user/controller/UserController.java index 7acf7e6..3ebeae4 100644 --- a/src/main/java/com/ll/spring_additional/boundedContext/user/controller/UserController.java +++ b/src/main/java/com/ll/spring_additional/boundedContext/user/controller/UserController.java @@ -1,40 +1,46 @@ package com.ll.spring_additional.boundedContext.user.controller; + import java.security.Principal; import java.util.List; -import jakarta.validation.Valid; - import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; import com.ll.spring_additional.base.exception.DataNotFoundException; import com.ll.spring_additional.boundedContext.answer.entity.Answer; import com.ll.spring_additional.boundedContext.answer.service.AnswerService; import com.ll.spring_additional.boundedContext.question.entity.Question; import com.ll.spring_additional.boundedContext.question.service.QuestionService; +import com.ll.spring_additional.boundedContext.user.Form.PWChangeForm; import com.ll.spring_additional.boundedContext.user.Form.UserCreateForm; +import com.ll.spring_additional.boundedContext.user.Form.UserPWFindForm; import com.ll.spring_additional.boundedContext.user.entity.SiteUser; import com.ll.spring_additional.boundedContext.user.service.UserService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Controller @RequestMapping("/user") public class UserController { - private final UserService userService; private final QuestionService questionService; private final AnswerService answerService; + private final PasswordEncoder passwordEncoder; + @GetMapping("/login") public String login() { return "user/login_form"; @@ -60,11 +66,11 @@ public String signup(@Valid UserCreateForm userCreateForm, BindingResult binding try { userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), userCreateForm.getPassword1()); - }catch(DataIntegrityViolationException e) { + } catch (DataIntegrityViolationException e) { e.printStackTrace(); bindingResult.reject("signupFailed", "이미 등록된 사용자입니다."); return "user/signup_form"; - }catch(Exception e) { + } catch (Exception e) { e.printStackTrace(); bindingResult.reject("signupFailed", e.getMessage()); return "user/signup_form"; @@ -74,11 +80,10 @@ public String signup(@Valid UserCreateForm userCreateForm, BindingResult binding @GetMapping("/mypage") @PreAuthorize("isAuthenticated()") - public String showmyPage(Model model, Principal principal) - { + public String showmyPage(Model model, Principal principal) { SiteUser user = userService.getUser(principal.getName()); - if(user == null) { + if (user == null) { throw new DataNotFoundException("사용자를 찾을 수 없습니다."); } model.addAttribute("user", user); @@ -97,4 +102,77 @@ public String showmyPage(Model model, Principal principal) return "user/my_page"; } + + @PreAuthorize("isAnonymous()") + @GetMapping("/pw_find") + public String showFindPassWord(UserPWFindForm userPWFindForm) { + return "user/pw_find"; + } + + @PreAuthorize("isAnonymous()") + @PostMapping("/pw_find") + public String findPassWord(Model model, @Valid UserPWFindForm userPWFindForm, BindingResult bindingResult, + RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + return "user/pw_find"; + } + + SiteUser user = userService.getUser(userPWFindForm.getUsername()); + + if (user == null) { + bindingResult.reject("notFindUser", "일치하는 사용자가 없습니다."); + return "user/pw_find"; + } + + if (!user.getEmail().equals(userPWFindForm.getEmail())) { + bindingResult.reject("notCorrectEmail", "등록된 회원 정보와 이메일이 다릅니다."); + return "user/pw_find"; + } + + String tempPW = userService.setTemporaryPW(user); + + // 이메일 전송 + // @Async 붙은 메서드는 동일한 클래스에서 호출할 수 없기에 컨트롤러에서 메일 발송 요청 + userService.sendEmail(userPWFindForm.getEmail(), user.getUsername(), tempPW); + + // 로그인 페이지에서 보여줄 성공 메시지를 플래시 애트리뷰트로 추가 + redirectAttributes.addFlashAttribute("successMessage", "임시 비밀번호가 이메일로 전송되었습니다. 이메일 확인 후 로그인 해주세요."); + + return "redirect:/user/login"; + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/change/passwd") + public String showChangePW(@ModelAttribute("pwChangeForm") PWChangeForm pwChangeForm) { + return "user/pw_change"; + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/change/passwd") + public String changePW(@Valid @ModelAttribute("pwChangeForm") PWChangeForm pwChangeForm, BindingResult bindingResult, Model model, + Principal principal, RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + return "user/pw_change"; + } + + SiteUser user = userService.getUser(principal.getName()); + + // 이전 패스워드와 맞지 않을경우 + if (!passwordEncoder.matches(pwChangeForm.getPrePassword(), user.getPassword())) { + bindingResult.reject("notMatchPW", "이전 비밀번호가 일치하지 않습니다."); + return "user/pw_change"; + } + // 새 비밀번호, 비밀번호 확인 창 일치하지 않을경우 + if (!pwChangeForm.getNewPassword1().equals(pwChangeForm.getNewPassword2())) { + bindingResult.reject("notMatchNewPW", "새 비밀번호와 확인이 일치하지 않습니다."); + return "user/pw_change"; + } + + userService.updatePassWord(user, pwChangeForm.getNewPassword1()); + + // 로그인 페이지에서 보여줄 성공 메시지를 플래시 애트리뷰트로 추가 + redirectAttributes.addFlashAttribute("successMessage", "비밀번호 변경 성공!"); + + return "redirect:/user/mypage"; + } } \ No newline at end of file diff --git a/src/main/java/com/ll/spring_additional/boundedContext/user/service/UserSecurityService.java b/src/main/java/com/ll/spring_additional/boundedContext/user/service/UserSecurityService.java index 406eec1..a04b8c3 100644 --- a/src/main/java/com/ll/spring_additional/boundedContext/user/service/UserSecurityService.java +++ b/src/main/java/com/ll/spring_additional/boundedContext/user/service/UserSecurityService.java @@ -12,7 +12,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; -import com.ll.spring_additional.boundedContext.user.UserRole; +import com.ll.spring_additional.boundedContext.user.userRole.UserRole; import com.ll.spring_additional.boundedContext.user.entity.SiteUser; import com.ll.spring_additional.boundedContext.user.repository.UserRepository; diff --git a/src/main/java/com/ll/spring_additional/boundedContext/user/service/UserService.java b/src/main/java/com/ll/spring_additional/boundedContext/user/service/UserService.java index 3519f29..dd36c9c 100644 --- a/src/main/java/com/ll/spring_additional/boundedContext/user/service/UserService.java +++ b/src/main/java/com/ll/spring_additional/boundedContext/user/service/UserService.java @@ -1,7 +1,12 @@ package com.ll.spring_additional.boundedContext.user.service; import java.util.Optional; +import java.util.Random; +import java.util.concurrent.Executor; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +26,12 @@ public class UserService { private final PasswordEncoder passwordEncoder; + private final JavaMailSender mailSender; + + private static final String ADMIN_ADDRESS = "r4560798@naver.com"; + + // private final Executor executor; + @Transactional public SiteUser create(String username, String email, String password) { SiteUser user = new SiteUser(); @@ -39,4 +50,45 @@ public SiteUser getUser(String username) { throw new DataNotFoundException("siteuser not found"); } } + + @Transactional + public String setTemporaryPW(SiteUser user) { + // 임시 비밀번호 생성 및 암호화 + String temporaryPassword = createRandomPassword(); + user.setPassword(passwordEncoder.encode(temporaryPassword)); + userRepository.save(user); + return temporaryPassword; + } + + @Async // 비동기 + public void sendEmail(String email, String userName, String tempPW) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(email); + message.setFrom(ADMIN_ADDRESS); + message.setSubject(userName+"님의 임시비밀번호 안내 메일입니다."); + message.setText("안녕하세요 "+userName+"님의 임시 비밀번호는 [" + tempPW +"] 입니다."); + + mailSender.send(message); + + } + + private String createRandomPassword() { + int leftLimit = 48; // numeral '0' + int rightLimit = 122; // letter 'z' + int targetStringLength = 10; + Random random = new Random(); + String generatedString = random.ints(leftLimit, rightLimit + 1) + .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97)) + .limit(targetStringLength) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + + return generatedString; + } + + @Transactional + public void updatePassWord(SiteUser user, String newPassword) { + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + } } \ No newline at end of file diff --git a/src/main/java/com/ll/spring_additional/boundedContext/user/UserRole.java b/src/main/java/com/ll/spring_additional/boundedContext/user/userRole/UserRole.java similarity index 73% rename from src/main/java/com/ll/spring_additional/boundedContext/user/UserRole.java rename to src/main/java/com/ll/spring_additional/boundedContext/user/userRole/UserRole.java index 8bf21dc..05117f0 100644 --- a/src/main/java/com/ll/spring_additional/boundedContext/user/UserRole.java +++ b/src/main/java/com/ll/spring_additional/boundedContext/user/userRole/UserRole.java @@ -1,4 +1,4 @@ -package com.ll.spring_additional.boundedContext.user; +package com.ll.spring_additional.boundedContext.user.userRole; import lombok.Getter; diff --git a/src/main/resources/application-secret.yml.template b/src/main/resources/application-secret.yml.template new file mode 100644 index 0000000..0f27ffd --- /dev/null +++ b/src/main/resources/application-secret.yml.template @@ -0,0 +1,11 @@ +spring: + mail: + host: smtp.naver.com + port: 465 + username: 이메일@naver.com + password: 비밀번호 + properties: + mail.smtp.auth: true + mail.smtp.ssl.enable: true + mail.smtp.ssl.trust: smtp.naver.com + mail.smtp.starttls.enable: true \ No newline at end of file diff --git a/src/main/resources/templates/common/form_errors.html b/src/main/resources/templates/common/form_errors.html index 80e0a78..62275a8 100644 --- a/src/main/resources/templates/common/form_errors.html +++ b/src/main/resources/templates/common/form_errors.html @@ -1,4 +1,4 @@