Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ 새 기능 : 비밀번호 재설정 기능 #208

Merged
merged 1 commit into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public class User {

private Authority authority;

private String snsType;

public UserEntity convertToEntity(){
return UserEntity.builder()
.id(this.id)
Expand All @@ -32,6 +34,7 @@ public UserEntity convertToEntity(){
.password(this.password)
.imageUrl(this.imageUrl)
.authority(this.authority)
.snsType(this.snsType)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -43,6 +45,7 @@ public User convertToDomain(){
.password(this.password)
.imageUrl(this.imageUrl)
.authority(this.authority)
.snsType(this.snsType)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +28,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.Optional;

@Service
Expand Down Expand Up @@ -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);
}
}

/**
* 중복되지 않은 랜덤 닉네임 생성
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Character> 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<Character>를 String으로 변환
StringBuilder password = new StringBuilder();
for (Character ch : passwordChars) {
password.append(ch);
}

return password.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}


Loading
Loading