Skip to content

Commit

Permalink
✨ 새 기능 : 비밀번호 재설정 기능
Browse files Browse the repository at this point in the history
1. 비밀번호 재설정 요청 시 요청 회원의 이메일로 재설정 임시 비밀번호를 담은 메일 전송 기능
2. 비밀번호 재설정 승인 시 메일에 포함된 임시 비밀번호로 비밀번호 재설정, 메인페이지로 이동 기능

Resolves: #149
  • Loading branch information
Aleph-Kim committed Jan 13, 2025
1 parent f088db2 commit 00631cf
Show file tree
Hide file tree
Showing 13 changed files with 271 additions and 5 deletions.
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

0 comments on commit 00631cf

Please sign in to comment.