From 405bba40ca481fceabc41bca4471b30733b10f4d Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sat, 16 Aug 2025 02:11:24 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20cors=20=ED=95=B4=EA=B2=B0=20#23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InsightPrep/global/config/CorsConfig.java | 25 +++++++++++++++++++ .../global/config/SecurityConfig.java | 3 +++ .../InsightPrep/global/config/WebConfig.java | 5 ++-- 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/project/InsightPrep/global/config/CorsConfig.java diff --git a/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java b/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java new file mode 100644 index 0000000..4e75ed7 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.project.InsightPrep.global.config; + +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:8080")); + cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS")); + cfg.setAllowedHeaders(List.of("*")); + cfg.setAllowCredentials(true); + cfg.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; + } +} diff --git a/src/main/java/com/project/InsightPrep/global/config/SecurityConfig.java b/src/main/java/com/project/InsightPrep/global/config/SecurityConfig.java index fb8a521..5f1aa44 100644 --- a/src/main/java/com/project/InsightPrep/global/config/SecurityConfig.java +++ b/src/main/java/com/project/InsightPrep/global/config/SecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfigurationSource; @Configuration @EnableWebSecurity @@ -26,6 +27,7 @@ public class SecurityConfig { private final CustomUserDetailsService userDetailsService; private final CustomAccessDeniedHandler customAccessDeniedHandler; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CorsConfigurationSource corsConfigurationSource; @Bean public PasswordEncoder passwordEncoder() { @@ -41,6 +43,7 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) // 개발 중 Swagger 등 요청 허용 + .cors(cors -> cors.configurationSource(corsConfigurationSource)) .anonymous(AbstractHttpConfigurer::disable) // 익명 세션 미적용 .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 세션 사용 가능하도록 설정 diff --git a/src/main/java/com/project/InsightPrep/global/config/WebConfig.java b/src/main/java/com/project/InsightPrep/global/config/WebConfig.java index a21caeb..973e8af 100644 --- a/src/main/java/com/project/InsightPrep/global/config/WebConfig.java +++ b/src/main/java/com/project/InsightPrep/global/config/WebConfig.java @@ -10,8 +10,9 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("http://localhost:8080") - .allowedMethods("GET", "POST", "PUT", "DELETE") + .allowedOrigins("http://localhost:8080", "http://localhost:5173") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") .allowCredentials(true); // 세션 쿠키 사용할 경우 true } } From d785095ebb0db2d6aee91e931bbc0897341b3fe4 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sat, 16 Aug 2025 17:38:16 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=84=B8=EC=85=98=EC=9D=84=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=98=EB=8A=94=20=EC=9A=A9=EB=8F=84=EC=9D=98=20api?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20#23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/controller/AuthController.java | 9 +++++++++ .../auth/controller/docs/AuthControllerDocs.java | 4 ++++ .../domain/auth/dto/response/AuthResponse.java | 8 ++++++++ .../domain/auth/exception/AuthErrorCode.java | 3 ++- .../domain/auth/service/AuthService.java | 4 ++++ .../domain/auth/service/AuthServiceImpl.java | 16 ++++++++++++++++ .../common/response/code/ApiSuccessCode.java | 3 ++- 7 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/project/InsightPrep/domain/auth/controller/AuthController.java b/src/main/java/com/project/InsightPrep/domain/auth/controller/AuthController.java index 944aead..f6485dd 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/controller/AuthController.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/controller/AuthController.java @@ -3,6 +3,7 @@ import com.project.InsightPrep.domain.auth.controller.docs.AuthControllerDocs; import com.project.InsightPrep.domain.auth.dto.request.AuthRequest; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; +import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto; import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.service.AuthService; @@ -17,6 +18,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +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; @@ -79,4 +81,11 @@ public ResponseEntity> logout (HttpServletRequest request, HttpSe return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.LOGOUT_SUCCESS)); } + + @PreAuthorize("hasAnyRole('USER', 'ADMIN')") + @GetMapping("/me") + public ResponseEntity> getSessionInfo(HttpSession session) { + MeDto dto = authService.getSessionInfo(session); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.ME_SUCCESS, dto)); + } } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/controller/docs/AuthControllerDocs.java b/src/main/java/com/project/InsightPrep/domain/auth/controller/docs/AuthControllerDocs.java index a998cbe..6245bae 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/controller/docs/AuthControllerDocs.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/controller/docs/AuthControllerDocs.java @@ -2,6 +2,7 @@ import com.project.InsightPrep.domain.auth.dto.request.AuthRequest; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; +import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto; import com.project.InsightPrep.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -29,4 +30,7 @@ public interface AuthControllerDocs { @Operation(summary = "로그아웃", description = "로그아웃을 진행하면 쿠키가 삭제됩니다.") public ResponseEntity> logout (HttpServletRequest request, HttpServletResponse response); + + @Operation(summary = "세션 조회", description = "로그인 한 사용자가 현재 나의 세션이 유효한지, 그리고 로그인한 사용자가 누구인지 조회합니다.") + public ResponseEntity> getSessionInfo(HttpSession session); } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/dto/response/AuthResponse.java b/src/main/java/com/project/InsightPrep/domain/auth/dto/response/AuthResponse.java index 0114f99..4691fb0 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/dto/response/AuthResponse.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/dto/response/AuthResponse.java @@ -27,4 +27,12 @@ public static class LoginResultDto { private Long memberId; private String nickname; } + + @Getter + @Builder + @JsonInclude(Include.NON_NULL) + public static class MeDto { + private Long memberId; + private String nickname; + } } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java index 386fa9e..0f2d12d 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java @@ -22,7 +22,8 @@ public enum AuthErrorCode implements BaseErrorCode { UNAUTHORIZED("UNAUTHORIZED", HttpStatus.UNAUTHORIZED, "비로그인 상태입니다."), MEMBER_NOT_FOUND("MEMBER_NOT_FOUND", HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다."), NOT_AUTHENTICATED("NOT_AUTHENTICATED", HttpStatus.UNAUTHORIZED, "로그인 정보가 없습니다."), - INVALID_AUTHENTICATION_PRINCIPAL("INVALID_AUTHENTICATION_PRINCIPAL", HttpStatus.FORBIDDEN, "인증 정보가 올바르지 않습니다."); + INVALID_AUTHENTICATION_PRINCIPAL("INVALID_AUTHENTICATION_PRINCIPAL", HttpStatus.FORBIDDEN, "인증 정보가 올바르지 않습니다."), + NEED_LOGIN_ERROR("NEED_LOGIN_ERROR", HttpStatus.BAD_REQUEST, "로그인이 필요합니다."); private final String code; diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthService.java b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthService.java index d002611..36905f8 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthService.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthService.java @@ -3,9 +3,13 @@ import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.LoginDto; import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.signupDto; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; +import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto; +import jakarta.servlet.http.HttpSession; public interface AuthService { void signup(signupDto request); LoginResultDto login(LoginDto request); + + MeDto getSessionInfo(HttpSession session); } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java index be82763..caa91f7 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/AuthServiceImpl.java @@ -3,12 +3,14 @@ import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.LoginDto; import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.signupDto; import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto; +import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto; import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.member.entity.Role; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; +import com.project.InsightPrep.global.auth.util.SecurityUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @@ -31,6 +33,7 @@ public class AuthServiceImpl implements AuthService { private final PasswordEncoder passwordEncoder; private final AuthMapper authMapper; private final EmailService emailService; + private final SecurityUtil securityUtil; @Override @Transactional @@ -83,6 +86,19 @@ public LoginResultDto login(LoginDto dto) { return new LoginResultDto(member.getId(), member.getNickname()); } + @Override + public MeDto getSessionInfo(HttpSession session) { + if (session == null) { + throw new AuthException(AuthErrorCode.NEED_LOGIN_ERROR); + } + + Member member = securityUtil.getAuthenticatedMember(); + return MeDto.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .build(); + } + private void validateSignUp(signupDto dto) { emailService.existEmail(dto.getEmail()); emailService.validateEmailVerified(dto.getEmail()); // 이메일 인증 여부 diff --git a/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java b/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java index d11393d..f1e43a0 100644 --- a/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java +++ b/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java @@ -21,7 +21,8 @@ public enum ApiSuccessCode { GET_FEEDBACK_SUCCESS("GET_FEEDBACK_SUCCESS", HttpStatus.OK, "피드백 조회 성공"), FEEDBACK_PENDING("FEEDBACK_PENDING", HttpStatus.ACCEPTED, "피드백 생성 중입니다."), GET_QUESTIONS_SUCCESS("GET_QUESTIONS_SUCCESS", HttpStatus.OK, "질문 리스트 조회 성공"), - DELETE_QUESTION_SUCCESS("DELETE_QUESTION_SUCCESS", HttpStatus.OK, "질문과 답변, 피드백 삭제 성공"); + DELETE_QUESTION_SUCCESS("DELETE_QUESTION_SUCCESS", HttpStatus.OK, "질문과 답변, 피드백 삭제 성공"), + ME_SUCCESS("ME_SUCCESS", HttpStatus.OK, "로그인 상태입니다."); private final String code; private final HttpStatus status; From 07dc6324440dd34dd6d6f0ed507ee1bc1ca5aa81 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sat, 16 Aug 2025 18:09:09 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=EC=A7=88=EB=AC=B8=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EB=8B=B5=EB=B3=80=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8B=B5=EB=B3=80=20id=20=EB=B0=98=ED=99=98=20#23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/controller/QuestionController.java | 4 ++-- .../domain/question/dto/response/AnswerResponse.java | 3 +-- .../domain/question/service/AnswerService.java | 3 ++- .../domain/question/service/impl/AnswerServiceImpl.java | 5 ++++- src/main/resources/mapper/answer-mapper.xml | 9 +++++---- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java b/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java index 9d3cca3..546ecaf 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java +++ b/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java @@ -45,8 +45,8 @@ public ResponseEntity> createQuestion( @PostMapping("/{questionId}/answer") @PreAuthorize("hasAnyRole('USER')") public ResponseEntity> saveAnswer(@RequestBody @Valid AnswerRequest.AnswerDto dto, @PathVariable Long questionId) { - answerService.saveAnswer(dto, questionId); - return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.SAVE_ANSWER_SUCCESS)); + AnswerDto answerDto = answerService.saveAnswer(dto, questionId); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.SAVE_ANSWER_SUCCESS, answerDto)); } @Override diff --git a/src/main/java/com/project/InsightPrep/domain/question/dto/response/AnswerResponse.java b/src/main/java/com/project/InsightPrep/domain/question/dto/response/AnswerResponse.java index 88eb80f..a37c04c 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/dto/response/AnswerResponse.java +++ b/src/main/java/com/project/InsightPrep/domain/question/dto/response/AnswerResponse.java @@ -12,8 +12,7 @@ public class AnswerResponse { @JsonInclude(Include.NON_NULL) public static class AnswerDto { - private long id; - private String content; + private long answerId; } @Getter diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java b/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java index a3cb023..78f3d89 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java @@ -1,10 +1,11 @@ package com.project.InsightPrep.domain.question.service; import com.project.InsightPrep.domain.question.dto.request.AnswerRequest.AnswerDto; +import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; public interface AnswerService { - void saveAnswer(AnswerDto dto, Long questionId); + AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId); void deleteAnswer(long answerId); } \ No newline at end of file diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java index 351482f..a844d40 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java @@ -2,6 +2,7 @@ import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.question.dto.request.AnswerRequest.AnswerDto; +import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.Question; @@ -29,7 +30,7 @@ public class AnswerServiceImpl implements AnswerService { @Override @Transactional - public void saveAnswer(AnswerDto dto, Long questionId) { + public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) { Member member = securityUtil.getAuthenticatedMember(); Question question = questionMapper.findById(questionId); @@ -42,6 +43,8 @@ public void saveAnswer(AnswerDto dto, Long questionId) { questionMapper.updateStatus(questionId, AnswerStatus.ANSWERED.name()); answerMapper.insertAnswer(answer); feedbackService.saveFeedback(answer); + return AnswerResponse.AnswerDto.builder() + .answerId(answer.getId()).build(); } @Override diff --git a/src/main/resources/mapper/answer-mapper.xml b/src/main/resources/mapper/answer-mapper.xml index 5d250dc..4543155 100644 --- a/src/main/resources/mapper/answer-mapper.xml +++ b/src/main/resources/mapper/answer-mapper.xml @@ -3,12 +3,13 @@ - - - SELECT currval(pg_get_serial_sequence('answer', 'id')) - + + + + INSERT INTO answer (created_at, updated_at, member_id, question_id, content) VALUES (NOW(), NOW(), #{member.id}, #{question.id}, #{content}) + RETURNING id + SELECT COUNT(*) + FROM answer a + INNER JOIN answer_feedback f ON f.answer_id = a.id + WHERE a.member_id = #{memberId} + + + SELECT id, email, code_hash AS codeHash, + attempts_left AS attemptsLeft, + used, expires_at AS expiresAt, + created_at AS createdAt, used_at AS usedAt, + reset_token AS resetToken, + reset_expires_at AS resetExpiresAt, + reset_used AS resetUsed + FROM password_verification + WHERE email = #{email} + + + + + UPDATE password_verification + SET attempts_left = #{attemptsLeft} + WHERE email = #{email} + + + + + UPDATE password_verification + SET used = TRUE, + used_at = NOW() + WHERE email = #{email} + + + + + UPDATE password_verification + SET reset_token = #{resetToken}, + reset_expires_at = #{resetExpiresAt}, + reset_used = #{resetUsed} + WHERE email = #{email} + + + + \ No newline at end of file From da9b4cf1ce9e52040deafa3ba3e0f03d8fe872f6 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sun, 17 Aug 2025 02:20:18 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20=EC=9E=AC=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20api=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/dto/request/AuthRequest.java | 2 +- .../domain/auth/exception/AuthErrorCode.java | 7 +++- .../domain/auth/mapper/AuthMapper.java | 3 ++ .../domain/auth/mapper/PasswordMapper.java | 4 +++ .../service/PasswordResetServiceImpl.java | 34 ++++++++++++++++++- .../global/auth/util/SecurityUtil.java | 13 +++++++ src/main/resources/mapper/auth-mapper.xml | 7 ++++ src/main/resources/mapper/password-mapper.xml | 19 +++++++++++ 8 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java b/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java index 6c4acd7..d0fe349 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java @@ -78,7 +78,7 @@ public record VerifyOtpReq( public static record ResetReq( @NotBlank String resetToken, - @NotBlank @Size(min = 8, max = 64) String newPassword + @NotBlank(message = "비밀번호는 필수입니다.") @Pattern(regexp = "^(?=.*[A-Z])(?=.*[@$!%*?&]).{8,16}$", message = "비밀번호는 영문 소문자, 대문자, 특수 문자로 구성되어야 합니다.") String newPassword ) {} public static record ResetTokenRes(String resetToken) {} diff --git a/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java index 46dce83..c6458ad 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java @@ -26,7 +26,12 @@ public enum AuthErrorCode implements BaseErrorCode { INVALID_AUTHENTICATION_PRINCIPAL("INVALID_AUTHENTICATION_PRINCIPAL", HttpStatus.FORBIDDEN, "인증 정보가 올바르지 않습니다."), NEED_LOGIN_ERROR("NEED_LOGIN_ERROR", HttpStatus.BAD_REQUEST, "로그인이 필요합니다."), OTP_INVALID("OTP_INVALID", HttpStatus.FORBIDDEN, "실패가 누적되어 인증 번호가 만료되었습니다."), - OTP_INVALID_ATTEMPT("OTP_INVALID_ATTEMPT", HttpStatus.BAD_REQUEST, "유효하지 않은 시도입니다."); + OTP_INVALID_ATTEMPT("OTP_INVALID_ATTEMPT", HttpStatus.BAD_REQUEST, "유효하지 않은 시도입니다."), + + RESET_TOKEN_INVALID("RESET_TOKEN_INVALID", HttpStatus.BAD_REQUEST, "비밀번호 재설정 토큰이 유효하지 않습니다."), + RESET_TOKEN_ALREADY_USED("RESET_TOKEN_ALREADY_USED", HttpStatus.BAD_REQUEST, "이미 사용된 재설정 토큰입니다."), + RESET_TOKEN_EXPIRED("RESET_TOKEN_EXPIRED", HttpStatus.BAD_REQUEST, "재설정 토큰이 만료되었습니다."), + SERVER_ERROR("SERVER_ERROR", HttpStatus.BAD_REQUEST, "서버 에러"); private final String code; diff --git a/src/main/java/com/project/InsightPrep/domain/auth/mapper/AuthMapper.java b/src/main/java/com/project/InsightPrep/domain/auth/mapper/AuthMapper.java index f2eaa8b..886fbb1 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/mapper/AuthMapper.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/mapper/AuthMapper.java @@ -15,4 +15,7 @@ public interface AuthMapper { Optional findByEmail(String email); Optional findById(@Param("id") Long id); + + int updatePasswordByEmail(@Param("email") String email, + @Param("passwordHash") String passwordHash); } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java b/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java index 4ed98cc..17a80b4 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/mapper/PasswordMapper.java @@ -26,4 +26,8 @@ int updateResetToken(@Param("email") String email, @Param("resetToken") String resetToken, @Param("resetUsed") boolean resetUsed, @Param("resetExpiresAt") LocalDateTime resetExpiresAt); + + PasswordVerification findByResetToken(@Param("resetToken") String resetToken); + + int markResetTokenUsed(@Param("resetToken") String resetToken); } diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java index 3d45e42..c0dd308 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImpl.java @@ -5,6 +5,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.mapper.AuthMapper; import com.project.InsightPrep.domain.auth.mapper.PasswordMapper; +import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.global.auth.util.SecurityUtil; import jakarta.mail.MessagingException; import java.time.LocalDateTime; @@ -116,8 +117,39 @@ public String verifyOtp(String email, String inputCode) { } @Override - public void resetPassword(String s, String s1) { + @Transactional + public void resetPassword(String resetToken, String newRawPassword) { + // 1) 토큰으로 레코드 조회 + PasswordVerification row = passwordMapper.findByResetToken(resetToken); + if (row == null) { + throw new AuthException(AuthErrorCode.RESET_TOKEN_INVALID); + } + + // 2) 토큰 사용 여부/만료 검사 + if (row.isResetUsed()) { + throw new AuthException(AuthErrorCode.RESET_TOKEN_ALREADY_USED); + } + if (row.getResetExpiresAt() == null || row.getResetExpiresAt().isBefore(LocalDateTime.now())) { + throw new AuthException(AuthErrorCode.RESET_TOKEN_EXPIRED); + } + // 3) 멤버 조회(존재 확인) + String email = row.getEmail(); + Member member = authMapper.findByEmail(email).orElseThrow(() -> new AuthException(AuthErrorCode.MEMBER_NOT_FOUND)); + + // 4) 패스워드 해시 & 저장 + String hashed = securityUtil.encode(newRawPassword); + int updated = authMapper.updatePasswordByEmail(email, hashed); + if (updated != 1) { + throw new AuthException(AuthErrorCode.SERVER_ERROR); + } + + // 5) 토큰 1회용 처리 + 무효화(선호에 따라 토큰 null 로도) + int done = passwordMapper.markResetTokenUsed(resetToken); + if (done != 1) { + // 여기서 실패하면 롤백 유도 + throw new AuthException(AuthErrorCode.SERVER_ERROR); + } } // 대문자+숫자 6자리 diff --git a/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java b/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java index 394ca15..066c8f2 100644 --- a/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java +++ b/src/main/java/com/project/InsightPrep/global/auth/util/SecurityUtil.java @@ -50,4 +50,17 @@ public boolean matches(String raw, String encoded) { } return passwordEncoder.matches(raw, encoded); } + + /** + * Encode a raw value using the configured PasswordEncoder. + * @param raw the plain text value to encode (must not be null) + * @return encoded hash + * @throws IllegalArgumentException if raw is null + */ + public String encode(String raw) { + if (raw == null) { + throw new IllegalArgumentException("raw must not be null"); + } + return passwordEncoder.encode(raw); + } } diff --git a/src/main/resources/mapper/auth-mapper.xml b/src/main/resources/mapper/auth-mapper.xml index 0de3494..4e806e5 100644 --- a/src/main/resources/mapper/auth-mapper.xml +++ b/src/main/resources/mapper/auth-mapper.xml @@ -20,4 +20,11 @@ SELECT * FROM member WHERE id = #{id} + + UPDATE member + SET password = #{passwordHash}, + updated_at = NOW() + WHERE email = #{email} + + \ No newline at end of file diff --git a/src/main/resources/mapper/password-mapper.xml b/src/main/resources/mapper/password-mapper.xml index b90d1ff..9c96084 100644 --- a/src/main/resources/mapper/password-mapper.xml +++ b/src/main/resources/mapper/password-mapper.xml @@ -53,6 +53,25 @@ WHERE email = #{email} + + + UPDATE password_verification + SET reset_used = TRUE, + used_at = NOW(), + reset_token = NULL, + reset_expires_at = NULL + WHERE reset_token = #{resetToken} + \ No newline at end of file From f5803a70e116571159e7623256d30057be7dbbbb Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sun, 17 Aug 2025 17:24:09 +0900 Subject: [PATCH 10/12] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?Service=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20#23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthControllerTest.java | 4 + .../service/PasswordResetServiceImplTest.java | 290 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java diff --git a/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java index 32dbe0f..5c92964 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java @@ -14,6 +14,7 @@ import com.project.InsightPrep.domain.auth.exception.AuthErrorCode; import com.project.InsightPrep.domain.auth.exception.AuthException; import com.project.InsightPrep.domain.auth.service.AuthService; +import com.project.InsightPrep.domain.auth.service.PasswordResetService; import com.project.InsightPrep.global.auth.domain.CustomUserDetails; import com.project.InsightPrep.domain.auth.service.EmailService; import com.project.InsightPrep.domain.member.entity.Member; @@ -54,6 +55,9 @@ class AuthControllerTest { @MockitoBean private CustomAuthenticationEntryPoint authenticationEntryPoint; + @MockitoBean + private PasswordResetService passwordResetService; + @Autowired private ObjectMapper objectMapper; diff --git a/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java new file mode 100644 index 0000000..6981352 --- /dev/null +++ b/src/test/java/com/project/InsightPrep/domain/auth/service/PasswordResetServiceImplTest.java @@ -0,0 +1,290 @@ +package com.project.InsightPrep.domain.auth.service; + +import com.project.InsightPrep.domain.auth.entity.PasswordVerification; +import com.project.InsightPrep.domain.auth.exception.AuthException; +import com.project.InsightPrep.domain.auth.mapper.AuthMapper; +import com.project.InsightPrep.domain.auth.mapper.PasswordMapper; +import com.project.InsightPrep.domain.member.entity.Member; +import com.project.InsightPrep.global.auth.util.SecurityUtil; +import jakarta.mail.MessagingException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PasswordResetServiceImplTest { + + @Mock EmailService emailService; + @Mock PasswordMapper passwordMapper; + @Mock AuthMapper authMapper; + @Mock + SecurityUtil securityUtil; + + @InjectMocks PasswordResetServiceImpl service; + + private static final String EMAIL = "user@example.com"; + + @BeforeEach + void setUp() { + // no-op + } + + @Test + @DisplayName("requestOtp: 가입된 이메일이면 OTP 저장 및 메일 발송") + void requestOtp_sendsMail_whenEmailExists() throws Exception { + when(authMapper.existEmail(EMAIL)).thenReturn(true); + + // execute + service.requestOtp(EMAIL); + + // verify: DB upsert & email sent + verify(passwordMapper).upsertPasswordOtp( + eq(EMAIL), + anyString(), // codeHash (BCrypt) + eq(5), // DEFAULT_ATTEMPTS + eq(false), // used + any(LocalDateTime.class), // expiresAt + any(LocalDateTime.class) // createdAt + ); + verify(emailService).sendEmail(eq(EMAIL), anyString(), contains("비밀번호 재설정 인증 코드")); + verifyNoMoreInteractions(emailService, passwordMapper); + } + + @Test + @DisplayName("requestOtp: 미가입 이메일이면 아무 동작 없이 정상 종료 (정보 유출 방지)") + void requestOtp_returnsSilently_whenEmailNotExist() throws Exception { + when(authMapper.existEmail(EMAIL)).thenReturn(false); + + service.requestOtp(EMAIL); + + verifyNoInteractions(emailService); + verifyNoInteractions(passwordMapper); + } + + @Test + @DisplayName("requestOtp: 메일 전송 실패 시 RuntimeException 전파") + void requestOtp_throws_whenSendMailFails() throws Exception { + when(authMapper.existEmail(EMAIL)).thenReturn(true); + doThrow(new MessagingException("smtp down")) + .when(emailService).sendEmail(anyString(), anyString(), anyString()); + + assertThatThrownBy(() -> service.requestOtp(EMAIL)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("메일 전송 실패"); + } + + // ---------- verifyOtp ---------- + @Test + @DisplayName("verifyOtp: 정상 - 코드 일치 시 사용 처리 후 resetToken 발급 및 저장") + void verifyOtp_success_issueResetToken() { + PasswordVerification row = otpRow(false, // used + LocalDateTime.now().plusMinutes(5), + "$2a$10$hash", // stored hash + 5, // attempts + false, // resetUsed + null, // resetExpiresAt + null // resetToken + ); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(securityUtil.matches(eq("ABC123"), anyString())).thenReturn(true); + when(passwordMapper.updateResetToken(eq(EMAIL), anyString(), eq(false), any(LocalDateTime.class))) + .thenReturn(1); + + String token = service.verifyOtp(EMAIL, "ABC123"); + + assertThat(token).isNotBlank(); + verify(passwordMapper).updateOtpAsUsed(EMAIL); + verify(passwordMapper).updateResetToken(eq(EMAIL), anyString(), eq(false), any(LocalDateTime.class)); + } + + @Test + @DisplayName("verifyOtp: 코드 불일치(남은 시도 > 0) → attempts 감소 후 예외") + void verifyOtp_decreaseAttempts_andThrow() { + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + "$2a$10$hash", 3, false, null, null); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(securityUtil.matches(eq("WRONG"), anyString())).thenReturn(false); + + assertThatThrownBy(() -> service.verifyOtp(EMAIL, "WRONG")) + .isInstanceOf(AuthException.class); + + verify(passwordMapper).updateAttempts(EMAIL, 2); + verify(passwordMapper, never()).updateOtpAsUsed(anyString()); + } + + @Test + @DisplayName("verifyOtp: 코드 불일치(마지막 시도) → used 처리 후 OTP_INVALID") + void verifyOtp_lastAttempt_marksUsed_andThrows() { + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + "$2a$10$hash", 1, false, null, null); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + when(securityUtil.matches(eq("WRONG"), anyString())).thenReturn(false); + + assertThatThrownBy(() -> service.verifyOtp(EMAIL, "WRONG")) + .isInstanceOf(AuthException.class); + + verify(passwordMapper).updateOtpAsUsed(EMAIL); + verify(passwordMapper, never()).updateAttempts(anyString(), anyInt()); + } + + @Test + @DisplayName("verifyOtp: 이미 사용된 OTP") + void verifyOtp_used_throws() { + PasswordVerification row = otpRow(true, LocalDateTime.now().plusMinutes(5), + "$2a$10$hash", 5, false, null, null); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + + assertThatThrownBy(() -> service.verifyOtp(EMAIL, "ANY")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("verifyOtp: 만료된 OTP") + void verifyOtp_expired_throws() { + PasswordVerification row = otpRow(false, LocalDateTime.now().minusSeconds(1), + "$2a$10$hash", 5, false, null, null); + + when(passwordMapper.findByEmail(EMAIL)).thenReturn(row); + + assertThatThrownBy(() -> service.verifyOtp(EMAIL, "ANY")) + .isInstanceOf(AuthException.class); + } + + // ---------- resetPassword ---------- + @Test + @DisplayName("resetPassword: 정상 - 토큰 유효, 비번 업데이트 및 토큰 사용 처리") + void resetPassword_success() { + String token = UUID.randomUUID().toString(); + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + null, 0, false, LocalDateTime.now().plusMinutes(10), token); + row.builder().email(EMAIL).build(); + + when(passwordMapper.findByResetToken(token)).thenReturn(row); + when(authMapper.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); + when(securityUtil.encode("newP@ss!")).thenReturn("hashed"); + when(authMapper.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); + when(passwordMapper.markResetTokenUsed(token)).thenReturn(1); + + service.resetPassword(token, "newP@ss!"); + + verify(authMapper).updatePasswordByEmail(EMAIL, "hashed"); + verify(passwordMapper).markResetTokenUsed(token); + } + + @Test + @DisplayName("resetPassword: 토큰 조회 불가 → RESET_TOKEN_INVALID") + void resetPassword_tokenNotFound() { + when(passwordMapper.findByResetToken("bad")).thenReturn(null); + + assertThatThrownBy(() -> service.resetPassword("bad", "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 이미 사용된 토큰 → RESET_TOKEN_ALREADY_USED") + void resetPassword_tokenAlreadyUsed() { + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + null, 0, true, LocalDateTime.now().plusMinutes(10), "token"); + when(passwordMapper.findByResetToken("token")).thenReturn(row); + + assertThatThrownBy(() -> service.resetPassword("token", "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 만료된 토큰 → RESET_TOKEN_EXPIRED") + void resetPassword_tokenExpired() { + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + null, 0, false, LocalDateTime.now().minusSeconds(1), "token"); + when(passwordMapper.findByResetToken("token")).thenReturn(row); + + assertThatThrownBy(() -> service.resetPassword("token", "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 회원 없음 → MEMBER_NOT_FOUND") + void resetPassword_memberNotFound() { + String token = "token"; + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + null, 0, false, LocalDateTime.now().plusMinutes(10), token); + row.builder().email(EMAIL).build(); + + when(passwordMapper.findByResetToken(token)).thenReturn(row); + when(authMapper.findByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.resetPassword(token, "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 비밀번호 업데이트 실패 → SERVER_ERROR") + void resetPassword_updatePasswordFail() { + String token = "token"; + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + null, 0, false, LocalDateTime.now().plusMinutes(10), token); + row.builder().email(EMAIL).build(); + + when(passwordMapper.findByResetToken(token)).thenReturn(row); + when(authMapper.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); + when(securityUtil.encode("pw")).thenReturn("hashed"); + when(authMapper.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(0); + + assertThatThrownBy(() -> service.resetPassword(token, "pw")) + .isInstanceOf(AuthException.class); + } + + @Test + @DisplayName("resetPassword: 토큰 사용 처리 실패 → SERVER_ERROR") + void resetPassword_markTokenFail() { + String token = "token"; + PasswordVerification row = otpRow(false, LocalDateTime.now().plusMinutes(5), + null, 0, false, LocalDateTime.now().plusMinutes(10), token); + row.builder().email(EMAIL).build(); + + when(passwordMapper.findByResetToken(token)).thenReturn(row); + when(authMapper.findByEmail(EMAIL)).thenReturn(Optional.of(Member.builder().email(EMAIL).build())); + when(securityUtil.encode("pw")).thenReturn("hashed"); + when(authMapper.updatePasswordByEmail(EMAIL, "hashed")).thenReturn(1); + when(passwordMapper.markResetTokenUsed(token)).thenReturn(0); + + assertThatThrownBy(() -> service.resetPassword(token, "pw")) + .isInstanceOf(AuthException.class); + } + + private PasswordVerification otpRow(boolean used, + LocalDateTime otpExpiresAt, + String codeHash, + int attemptsLeft, + boolean resetUsed, + LocalDateTime resetExpiresAt, + String resetToken) { + PasswordVerification p = PasswordVerification.builder() + .email(EMAIL) + .used(used) + .expiresAt(otpExpiresAt) + .codeHash(codeHash) + .attemptsLeft(attemptsLeft) + .resetUsed(resetUsed) + .resetExpiresAt(resetExpiresAt) + .resetToken(resetToken) + .build(); + return p; + } +} \ No newline at end of file From c5b845fc76f60c6235e4e8a7e6edd15dbe517277 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sun, 17 Aug 2025 17:37:20 +0900 Subject: [PATCH 11/12] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?Controller=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20#23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/dto/request/AuthRequest.java | 1 + .../domain/auth/exception/AuthErrorCode.java | 2 +- .../auth/controller/AuthControllerTest.java | 127 ++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java b/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java index d0fe349..bebf625 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/dto/request/AuthRequest.java @@ -41,6 +41,7 @@ public static class signupDto { @Getter @NoArgsConstructor + @Setter public static class MemberEmailDto { @NotBlank @Email diff --git a/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java index c6458ad..f1cf8c8 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/exception/AuthErrorCode.java @@ -16,7 +16,7 @@ public enum AuthErrorCode implements BaseErrorCode { // 이메일 EMAIL_DUPLICATE_ERROR("EMAIL_DUPLICATE_ERROR", HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), EMAIL_VERIFICATION_ERROR("EMAIL_VERIFICATION_ERROR", HttpStatus.BAD_REQUEST, "인증된 이메일이 아닙니다."), - EXPIRED_CODE_ERROR("LINK_EXPIRED_ERROR", HttpStatus.BAD_REQUEST, "코드 입력 시간이 만료되었습니다."), + EXPIRED_CODE_ERROR("EXPIRED_CODE_ERROR", HttpStatus.BAD_REQUEST, "코드 입력 시간이 만료되었습니다."), ALREADY_SEND_CODE_ERROR("ALREADY_SEND_CODE_ERROR", HttpStatus.BAD_REQUEST, "이미 유효한 인증 코드가 발급되었습니다."), OTP_ALREADY_USED("OTP_ALREADY_USED", HttpStatus.BAD_REQUEST, "이미 사용한 인증 코드입니다."), CODE_NOT_MATCH_ERROR("CODE_NOT_MATCH_ERROR", HttpStatus.BAD_REQUEST, "인증 코드가 일치하지 않습니다"), diff --git a/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java index 5c92964..59201ad 100644 --- a/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/project/InsightPrep/domain/auth/controller/AuthControllerTest.java @@ -1,7 +1,10 @@ package com.project.InsightPrep.domain.auth.controller; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -255,6 +258,130 @@ void logout_success() throws Exception { .andExpect(jsonPath("$.message").value("로그아웃 성공")); } + @Test + @DisplayName("OTP 이메일 요청 - 항상 200 (존재 여부 노출 금지)") + void requestOtp_success_always200() throws Exception { + // given + AuthRequest.MemberEmailDto req = new AuthRequest.MemberEmailDto(); + req.setEmail("test@example.com"); + + doNothing().when(passwordResetService).requestOtp("test@example.com"); + + // when & then + mockMvc.perform(post("/auth/otp/sendEmail") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SEND_EMAIL_SUCCESS")); + } + + @Test + @DisplayName("OTP 인증 성공 - 토큰 반환") + void verifyOtp_success_returnsToken() throws Exception { + // given + String email = "test@example.com"; + String code = "ABC123"; + String token = "reset-token-123"; + + AuthRequest.VerifyOtpReq req = new AuthRequest.VerifyOtpReq(email, code); + + given(passwordResetService.verifyOtp(email, code)).willReturn(token); + + // when & then + mockMvc.perform(post("/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("VERIFIED_EMAIL_SUCCESS")) + .andExpect(jsonPath("$.result.resetToken").value(token)); + } + + @Test + @DisplayName("OTP 인증 실패 - 코드 불일치") + void verifyOtp_fail_codeNotMatch() throws Exception { + // given + String email = "test@example.com"; + String code = "WRONG"; + AuthRequest.VerifyOtpReq req = new AuthRequest.VerifyOtpReq(email, code); + + given(passwordResetService.verifyOtp(email, code)) + .willThrow(new AuthException(AuthErrorCode.CODE_NOT_MATCH_ERROR)); + + // when & then + mockMvc.perform(post("/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CODE_NOT_MATCH_ERROR")); + } + + @Test + @DisplayName("OTP 인증 실패 - 만료됨") + void verifyOtp_fail_expired() throws Exception { + // given + AuthRequest.VerifyOtpReq req = new AuthRequest.VerifyOtpReq("test@example.com", "ABC123"); + + given(passwordResetService.verifyOtp(anyString(), anyString())) + .willThrow(new AuthException(AuthErrorCode.EXPIRED_CODE_ERROR)); + + // when & then + mockMvc.perform(post("/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("EXPIRED_CODE_ERROR")); + } + + @Test + @DisplayName("비밀번호 재설정 성공") + void resetPassword_success() throws Exception { + // given + AuthRequest.ResetReq req = new AuthRequest.ResetReq("reset-token-123", "NewPassword1!"); + + doNothing().when(passwordResetService).resetPassword("reset-token-123", "NewPassword1!"); + + // when & then + mockMvc.perform(post("/auth/password/reset") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS")); + } + + @Test + @DisplayName("비밀번호 재설정 실패 - 토큰 무효") + void resetPassword_fail_invalidToken() throws Exception { + // given + AuthRequest.ResetReq req = new AuthRequest.ResetReq("bad-token", "NewPassword1!"); + + doThrow(new AuthException(AuthErrorCode.RESET_TOKEN_INVALID)) + .when(passwordResetService).resetPassword("bad-token", "NewPassword1!"); + + // when & then + mockMvc.perform(post("/auth/password/reset") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("RESET_TOKEN_INVALID")); + } + + @Test + @DisplayName("비밀번호 재설정 실패 - 토큰 만료") + void resetPassword_fail_expiredToken() throws Exception { + // given + AuthRequest.ResetReq req = new AuthRequest.ResetReq("expired-token", "NewPassword1!"); + + doThrow(new AuthException(AuthErrorCode.RESET_TOKEN_EXPIRED)) + .when(passwordResetService).resetPassword("expired-token", "NewPassword1!"); + + // when & then + mockMvc.perform(post("/auth/password/reset") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("RESET_TOKEN_EXPIRED")); + } + // 테스트에서 인증 객체 설정을 위한 헬퍼 메서드 private static RequestPostProcessor authentication(Authentication authentication) { return SecurityMockMvcRequestPostProcessors.authentication(authentication); From 2dfc3539df929c80cb1302767986c8ca4e81fb08 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Sun, 17 Aug 2025 17:53:50 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20pr=20review=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20#23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InsightPrep/domain/auth/service/PasswordResetService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetService.java b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetService.java index aa7048b..022888e 100644 --- a/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetService.java +++ b/src/main/java/com/project/InsightPrep/domain/auth/service/PasswordResetService.java @@ -5,5 +5,5 @@ public interface PasswordResetService { String verifyOtp(String email, String code); - void resetPassword(String s, String s1); + void resetPassword(String resetToken, String newRawPassword); }