diff --git a/src/main/java/darkoverload/itzip/feature/jwt/util/JwtTokenizer.java b/src/main/java/darkoverload/itzip/feature/jwt/util/JwtTokenizer.java index 9f2ddaca..c3e1e873 100644 --- a/src/main/java/darkoverload/itzip/feature/jwt/util/JwtTokenizer.java +++ b/src/main/java/darkoverload/itzip/feature/jwt/util/JwtTokenizer.java @@ -24,16 +24,20 @@ public class JwtTokenizer { private final byte[] accessSecret; private final byte[] refreshSecret; + private final byte[] tempPwSecret; public static Long accessTokenExpire; public static Long refreshTokenExpire; + public static Long tempPwExpire; public static final String AUTHORIZATION_HEADER = "Authorization"; - public JwtTokenizer(@Value("${jwt.accessSecret}") String accessSecret, @Value("${jwt.refreshSecret}") String refreshSecret, @Value("${jwt.accessTokenExpire}") Long accessTokenExpire, @Value("${jwt.refreshTokenExpire}") Long refreshTokenExpire) { + public JwtTokenizer(@Value("${jwt.accessSecret}") String accessSecret, @Value("${jwt.refreshSecret}") String refreshSecret, @Value("${jwt.tempPwSecret}") String tempPwSecret, @Value("${jwt.accessTokenExpire}") Long accessTokenExpire, @Value("${jwt.refreshTokenExpire}") Long refreshTokenExpire, @Value("${jwt.tempPwExpire}") Long tempPwExpire) { this.accessSecret = accessSecret.getBytes(StandardCharsets.UTF_8); this.refreshSecret = refreshSecret.getBytes(StandardCharsets.UTF_8); + this.tempPwSecret = tempPwSecret.getBytes(StandardCharsets.UTF_8); this.accessTokenExpire = accessTokenExpire; this.refreshTokenExpire = refreshTokenExpire; + this.tempPwExpire = tempPwExpire; } /** @@ -160,4 +164,31 @@ public String resolveAccessToken(HttpServletRequest request) { } return null; } + + /** + * 비밀번호 재설정 토큰 생성 메서드 + * @param email 유저 이메일 + * @param tempPassword 임시 비밀번호 + * @return 비밀번호 재설정 토큰 + */ + public String createTempPasswordToken(String email, String tempPassword) { + long now = System.currentTimeMillis(); + return Jwts.builder() + .setSubject("TempPassword") + .claim("email", email) + .claim("tempPassword", tempPassword) + .setIssuedAt(new Date(now)) + .setExpiration(new Date(now + tempPwExpire)) + .signWith(getSigningKey(tempPwSecret)) + .compact(); + } + + /** + * 비밀번호 재설정 토큰 파싱 메서드 + * @param tempPwToken 비밀번호 재설정 토큰 + * @return 파싱된 토큰 + */ + public Claims parseTempPwToken(String tempPwToken) { + return parseToken(tempPwToken, tempPwSecret); + } } \ No newline at end of file diff --git a/src/main/java/darkoverload/itzip/feature/user/controller/UserController.java b/src/main/java/darkoverload/itzip/feature/user/controller/UserController.java index f9be4b57..b522026e 100644 --- a/src/main/java/darkoverload/itzip/feature/user/controller/UserController.java +++ b/src/main/java/darkoverload/itzip/feature/user/controller/UserController.java @@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -162,4 +163,34 @@ public String tempUserOut( ) { return userService.tempUserOut(userDetails, request); } + + /** + * 비밀번호 재설정 요청 메소드 + */ + @Operation( + summary = "비밀번호 재설정 요청", + description = "비밀번호 재설정 메일을 발송합니다." + ) + @ResponseCodeAnnotation(CommonResponseCode.SUCCESS) + @ExceptionCodeAnnotations({CommonExceptionCode.INTERNAL_SERVER_ERROR, CommonExceptionCode.NOT_FOUND_USER}) + @PostMapping("/passwordReset") + public String requestPasswordReset(HttpServletRequest request, + @RequestBody @Valid PasswordResetRequest passwordResetRequest) { + return userService.requestPasswordReset(passwordResetRequest, request); + } + + /** + * 비밀번호 재설정 승인 메소드 + */ + @Operation( + summary = "비밀번호 재설정 승인", + description = "비밀번호 재설정 후 메인페이지로 이동합니다." + ) + @ResponseCodeAnnotation(CommonResponseCode.SUCCESS) + @ExceptionCodeAnnotations({CommonExceptionCode.INTERNAL_SERVER_ERROR, CommonExceptionCode.NOT_FOUND_USER}) + @GetMapping("/passwordReset") + public void confirmPasswordReset(HttpServletResponse response, + @Parameter(description = "유저 정보를 포함한 토큰값") @RequestParam @NotBlank String token) { + userService.confirmPasswordReset(response, token); + } } diff --git a/src/main/java/darkoverload/itzip/feature/user/controller/request/PasswordResetRequest.java b/src/main/java/darkoverload/itzip/feature/user/controller/request/PasswordResetRequest.java new file mode 100644 index 00000000..35eda0bc --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/controller/request/PasswordResetRequest.java @@ -0,0 +1,20 @@ +package darkoverload.itzip.feature.user.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 비밀번호 재설정 요청 dto + */ +@Getter +@AllArgsConstructor +public class PasswordResetRequest { + @NotEmpty(message = "이메일을 입력해주세요.") + @Pattern(regexp = "^[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*\\.[a-zA-Z]{2,3}$", + message = "올바르지 않은 이메일 형식입니다.") + @Schema(description = "이메일", example = "example@gmail.com") + private String email; +} diff --git a/src/main/java/darkoverload/itzip/feature/user/domain/User.java b/src/main/java/darkoverload/itzip/feature/user/domain/User.java index 53539aee..235611fc 100644 --- a/src/main/java/darkoverload/itzip/feature/user/domain/User.java +++ b/src/main/java/darkoverload/itzip/feature/user/domain/User.java @@ -24,6 +24,8 @@ public class User { private Authority authority; + private String snsType; + public UserEntity convertToEntity(){ return UserEntity.builder() .id(this.id) @@ -32,6 +34,7 @@ public UserEntity convertToEntity(){ .password(this.password) .imageUrl(this.imageUrl) .authority(this.authority) + .snsType(this.snsType) .build(); } } diff --git a/src/main/java/darkoverload/itzip/feature/user/entity/UserEntity.java b/src/main/java/darkoverload/itzip/feature/user/entity/UserEntity.java index 22872ef7..bc19d0b2 100644 --- a/src/main/java/darkoverload/itzip/feature/user/entity/UserEntity.java +++ b/src/main/java/darkoverload/itzip/feature/user/entity/UserEntity.java @@ -35,6 +35,8 @@ public class UserEntity extends AuditingFields { @Enumerated(EnumType.STRING) private Authority authority; + private String snsType; + public User convertToDomain(){ return User.builder() .id(this.id) @@ -43,6 +45,7 @@ public User convertToDomain(){ .password(this.password) .imageUrl(this.imageUrl) .authority(this.authority) + .snsType(this.snsType) .build(); } } diff --git a/src/main/java/darkoverload/itzip/feature/user/service/EmailService.java b/src/main/java/darkoverload/itzip/feature/user/service/EmailService.java index 6b758c5e..8953175b 100644 --- a/src/main/java/darkoverload/itzip/feature/user/service/EmailService.java +++ b/src/main/java/darkoverload/itzip/feature/user/service/EmailService.java @@ -6,4 +6,6 @@ public interface EmailService { void sendFormMail(String to, String subject, String body); String setAuthForm(String authCode); + + String setPwResetMail(String resetLink, String tempPassword); } diff --git a/src/main/java/darkoverload/itzip/feature/user/service/EmailServiceImpl.java b/src/main/java/darkoverload/itzip/feature/user/service/EmailServiceImpl.java index c28e8259..ac8f4224 100644 --- a/src/main/java/darkoverload/itzip/feature/user/service/EmailServiceImpl.java +++ b/src/main/java/darkoverload/itzip/feature/user/service/EmailServiceImpl.java @@ -91,4 +91,33 @@ public String setAuthForm(String authcode) { return htmlContent; } + /** + * 비밀번호 재설정 메일 폼에 재설정 링크와 임시 비밀번호를 추가하여 반환하는 메소드 + * @param resetLink + * @param tempPassword + * @return + */ + @Override + public String setPwResetMail(String resetLink, String tempPassword) { + // 인증메일 폼 경로 설정 + String templatePath = "/templates/tempPasswordForm.html"; + + // 인증메일 폼 읽어오기 + String htmlContent = null; + try (InputStream inputStream = getClass().getResourceAsStream(templatePath)) { + if (inputStream == null) { + throw new RestApiException(CommonExceptionCode.INTERNAL_SERVER_ERROR); + } + htmlContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RestApiException(CommonExceptionCode.INTERNAL_SERVER_ERROR); + } + + // 폼에 재설정 링크와 임시 비밀번호를 추가 + htmlContent = htmlContent.replace("{{resetLink}}", resetLink); + htmlContent = htmlContent.replace("{{tempPassword}}", tempPassword); + + return htmlContent; + } + } diff --git a/src/main/java/darkoverload/itzip/feature/user/service/UserService.java b/src/main/java/darkoverload/itzip/feature/user/service/UserService.java index 4e201998..845d3d74 100644 --- a/src/main/java/darkoverload/itzip/feature/user/service/UserService.java +++ b/src/main/java/darkoverload/itzip/feature/user/service/UserService.java @@ -43,4 +43,8 @@ public interface UserService { String encryptPassword(String password); String tempUserOut(CustomUserDetails userDetails, HttpServletRequest request); + + String requestPasswordReset(PasswordResetRequest passwordResetRequest, HttpServletRequest request); + + void confirmPasswordReset(HttpServletResponse response, String token); } diff --git a/src/main/java/darkoverload/itzip/feature/user/service/UserServiceImpl.java b/src/main/java/darkoverload/itzip/feature/user/service/UserServiceImpl.java index 3a930e83..49ae1dfa 100644 --- a/src/main/java/darkoverload/itzip/feature/user/service/UserServiceImpl.java +++ b/src/main/java/darkoverload/itzip/feature/user/service/UserServiceImpl.java @@ -5,22 +5,21 @@ import darkoverload.itzip.feature.jwt.service.TokenService; import darkoverload.itzip.feature.jwt.util.JwtTokenizer; import darkoverload.itzip.feature.techinfo.service.blog.BlogCommandService; -import darkoverload.itzip.feature.user.controller.request.AuthEmailSendRequest; -import darkoverload.itzip.feature.user.controller.request.RefreshAccessTokenRequest; -import darkoverload.itzip.feature.user.controller.request.UserJoinRequest; -import darkoverload.itzip.feature.user.controller.request.UserLoginRequest; +import darkoverload.itzip.feature.user.controller.request.*; import darkoverload.itzip.feature.user.controller.response.UserInfoResponse; import darkoverload.itzip.feature.user.controller.response.UserLoginResponse; import darkoverload.itzip.feature.user.domain.User; import darkoverload.itzip.feature.user.entity.Authority; import darkoverload.itzip.feature.user.entity.UserEntity; import darkoverload.itzip.feature.user.repository.UserRepository; +import darkoverload.itzip.feature.user.util.PasswordUtil; import darkoverload.itzip.feature.user.util.RandomAuthCode; import darkoverload.itzip.feature.user.util.RandomNickname; import darkoverload.itzip.global.config.response.code.CommonExceptionCode; import darkoverload.itzip.global.config.response.exception.RestApiException; import io.jsonwebtoken.Claims; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -29,6 +28,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.util.Optional; @Service @@ -264,6 +264,62 @@ public String tempUserOut(CustomUserDetails userDetails, HttpServletRequest requ return "정상적으로 탈퇴 되었습니다."; } + /** + * 비밀번호 재설정 요청 메서드 + * @param passwordResetRequest 비밀번호 재설정 요청 dto + * @param request 요청 객체 + */ + @Override + public String requestPasswordReset(PasswordResetRequest passwordResetRequest, HttpServletRequest request) { + String email = passwordResetRequest.getEmail(); + + // 올바른 이메일인지 체크 + User user = getByEmail(email); + + String tempPassword = PasswordUtil.generatePassword(); + + // JWT 토큰 생성 + String token = jwtTokenizer.createTempPasswordToken(email, tempPassword); + + // 비밀번호 변경 링크 생성 + String appUrl = request.getRequestURL().toString().replace(request.getRequestURI(), request.getContextPath()); + String resetLink = appUrl + "/user/passwordReset?token=" + token; + + // 메일 제목 + String subject = "[ITZIP] 비밀번호 재설정"; + + // 메일 본문 + String body = emailService.setPwResetMail(resetLink, tempPassword); + + // 메일 발송 + emailService.sendFormMail(email, subject, body); + return "비밀번호 초기화 메일이 전송되었습니다."; + } + + /** + * 비밀번호 재설정 승인 메서드 + * @param response 응답 객체 + * @param token 비밀번호 재설정 토큰 + */ + @Override + public void confirmPasswordReset(HttpServletResponse response, String token) { + Claims claims = jwtTokenizer.parseTempPwToken(token); + + String email = claims.get("email", String.class); + String tempPassword = claims.get("tempPassword", String.class); + + User user = getByEmail(email); + user.setPassword(encryptPassword(tempPassword)); + + userRepository.save(user.convertToEntity()); + + try { + response.sendRedirect("https://itzip.co.kr"); + } catch (IOException e) { + throw new RestApiException(CommonExceptionCode.INTERNAL_SERVER_ERROR); + } + } + /** * 중복되지 않은 랜덤 닉네임 생성 * diff --git a/src/main/java/darkoverload/itzip/feature/user/util/PasswordUtil.java b/src/main/java/darkoverload/itzip/feature/user/util/PasswordUtil.java new file mode 100644 index 00000000..6cbb6734 --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/util/PasswordUtil.java @@ -0,0 +1,54 @@ +package darkoverload.itzip.feature.user.util; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Component +public class PasswordUtil { + + // 사용 가능한 문자 집합 + private static final String LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String DIGITS = "0123456789"; + private static final String SPECIALS = "!@#$%^&*?_"; + + private static final SecureRandom random = new SecureRandom(); + + /** + * 주어진 정규식 조건을 만족하는 랜덤 비밀번호를 생성 + * 정규식: ^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*?_]).{8,16}$ + * + * @return 조건에 맞는 랜덤 비밀번호 + */ + public static String generatePassword() { + // 비밀번호 길이를 8~16 사이로 랜덤 선택 + int passwordLength = 8 + random.nextInt(9); // 8 ~ 16 + + List passwordChars = new ArrayList<>(); + + // 각 그룹에서 최소 1개씩 추가하여 정규식 조건 만족 + passwordChars.add(LETTERS.charAt(random.nextInt(LETTERS.length()))); + passwordChars.add(DIGITS.charAt(random.nextInt(DIGITS.length()))); + passwordChars.add(SPECIALS.charAt(random.nextInt(SPECIALS.length()))); + + // 남은 길이만큼 임의의 문자 추가 + String allAllowed = LETTERS + DIGITS + SPECIALS; + for (int i = 3; i < passwordLength; i++) { + passwordChars.add(allAllowed.charAt(random.nextInt(allAllowed.length()))); + } + + // 문자 순서를 섞어서 예측 불가하게 만듦 + Collections.shuffle(passwordChars, random); + + // List를 String으로 변환 + StringBuilder password = new StringBuilder(); + for (Character ch : passwordChars) { + password.append(ch); + } + + return password.toString(); + } +} diff --git a/src/main/java/darkoverload/itzip/global/config/security/SecurityConfig.java b/src/main/java/darkoverload/itzip/global/config/security/SecurityConfig.java index 849d56c5..27001691 100644 --- a/src/main/java/darkoverload/itzip/global/config/security/SecurityConfig.java +++ b/src/main/java/darkoverload/itzip/global/config/security/SecurityConfig.java @@ -33,6 +33,7 @@ public class SecurityConfig { "/user/refreshToken", // 토큰 재발급 페이지 "/user/authEmail", // 인증 메일 페이지 "/user/checkDuplicateEmail", // 이메일 중복 체크 페이지 + "/user/passwordReset", // 이메일 중복 체크 페이지 "/swagger-ui/**", // Swagger UI "/v3/api-docs/**", // Swagger API docs "/swagger-resources/**", // Swagger resources diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 7e6530c0..d267cb62 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -60,7 +60,9 @@ file: jwt: accessSecret: ${LOC_JWT_ACCESS_SECRET} refreshSecret: ${LOC_JWT_REFRESH_SECRET} + tempPwSecret: ${LOC_JWT_TEMP_SECRET} accessTokenExpire: ${LOC_JWT_ACCESS_EXPIRE} refreshTokenExpire: ${LOC_JWT_REFRESH_EXPIRE} + tempPwExpire: ${LOC_JWT_TEMP_EXPIRE} diff --git a/src/main/resources/templates/tempPasswordForm.html b/src/main/resources/templates/tempPasswordForm.html new file mode 100644 index 00000000..c43f60f7 --- /dev/null +++ b/src/main/resources/templates/tempPasswordForm.html @@ -0,0 +1,30 @@ + + + + + + ITZIP 비밀번호 재설정 + + +
+
+ ITZIP Logo +
+
+

비밀번호 재설정

+

안녕하세요, ITZIP입니다.

+

아래 버튼 클릭 시 비밀번호를 재설정한 후 ITZIP 메인페이지로 이동합니다.

+

임시 비밀번호로 로그인해주세요!

+
임시 비밀번호 : {{tempPassword}}
+ + 비밀번호 재설정 + +
+
+

본 메일은 발신전용 메일로 회신되지 않습니다.

+

© 2024 ITZIP. All rights reserved.

+
+
+ +