From 587946aa53a899080721dcae92eee9cb96fcbd27 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Fri, 8 May 2026 15:34:33 +0900 Subject: [PATCH] Add request validation hardening --- ontime-back/build.gradle | 1 + .../ontime_back/config/SecurityConfig.java | 10 +- .../controller/AlarmController.java | 11 +- .../controller/FeedbackController.java | 5 +- .../controller/FirebaseTokenController.java | 3 +- .../controller/FriendShipController.java | 9 +- .../PreparationScheduleController.java | 8 +- .../controller/PreparationUserController.java | 6 +- .../controller/ScheduleController.java | 7 +- .../controller/SocialAuthController.java | 11 +- .../controller/UserAuthController.java | 12 +- .../controller/UserController.java | 6 +- .../controller/UserSettingController.java | 3 +- .../dto/AlarmDeviceCurrentRequestDto.java | 15 ++ .../dto/AlarmDeviceUnregisterRequestDto.java | 2 + .../dto/AlarmSettingsPatchDto.java | 69 ++++++ .../dto/AlarmStatusFailureDto.java | 4 + .../dto/AlarmStatusReportRequestDto.java | 37 +++- .../ontime_back/dto/ChangePasswordDto.java | 10 + .../ontime_back/dto/FeedbackAddDto.java | 2 + .../ontime_back/dto/FinishPreparationDto.java | 8 + .../ontime_back/dto/FirebaseTokenAddDto.java | 9 + .../ontime_back/dto/LoginRequestDto.java | 20 ++ .../ontime_back/dto/OAuthAppleRequestDto.java | 15 +- .../dto/OAuthGoogleRequestDto.java | 7 + .../ontime_back/dto/OAuthKakaoUserDto.java | 14 +- .../ontime_back/dto/PreparationDto.java | 13 ++ .../ontime_back/dto/ScheduleAddDto.java | 22 ++ .../ontime_back/dto/ScheduleModDto.java | 21 ++ .../dto/UpdateAcceptStatusDto.java | 6 + .../ontime_back/dto/UpdateSpareTimeDto.java | 6 + .../ontime_back/dto/UserOnboardingDto.java | 21 +- .../ontime_back/dto/UserSettingUpdateDto.java | 4 + .../devkor/ontime_back/dto/UserSignUpDto.java | 13 +- ...nUsernamePasswordAuthenticationFilter.java | 43 +++- .../global/oauth/apple/AppleLoginFilter.java | 32 ++- .../oauth/google/GoogleLoginFilter.java | 25 ++- .../global/oauth/kakao/KakaoLoginFilter.java | 25 ++- .../ontime_back/response/ApiResponseForm.java | 5 +- .../response/GlobalExceptionHandler.java | 80 ++++++- .../response/ValidationErrorResponse.java | 9 + .../response/ValidationErrorWriter.java | 47 ++++ .../ontime_back/service/AlarmService.java | 12 ++ .../ontime_back/ControllerTestSupport.java | 27 ++- .../RequestValidationControllerTest.java | 201 ++++++++++++++++++ .../controller/ScheduleControllerTest.java | 2 +- .../controller/UserAuthControllerTest.java | 4 +- .../controller/UserControllerTest.java | 4 +- ...rnamePasswordAuthenticationFilterTest.java | 40 +++- .../oauth/OAuthLoginFilterValidationTest.java | 124 +++++++++++ .../src/test/resources/application.properties | 29 +++ 51 files changed, 1042 insertions(+), 77 deletions(-) create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsPatchDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/LoginRequestDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorResponse.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorWriter.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/controller/RequestValidationControllerTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java create mode 100644 ontime-back/src/test/resources/application.properties diff --git a/ontime-back/build.gradle b/ontime-back/build.gradle index 52a235b8..9ce28beb 100644 --- a/ontime-back/build.gradle +++ b/ontime-back/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' implementation 'org.springframework.boot:spring-boot-starter-actuator' compileOnly 'org.projectlombok:lombok' diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java index 43ce7baa..06ac1777 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java @@ -17,6 +17,7 @@ import devkor.ontime_back.global.oauth.google.GoogleLoginFilter; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; +import jakarta.validation.Validator; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -54,6 +55,7 @@ public class SecurityConfig { private final UserRepository userRepository; private final UserAlarmSettingRepository userAlarmSettingRepository; private final ObjectMapper objectMapper; + private final Validator validator; private final AppleLoginService appleLoginService; private final GoogleLoginService googleLoginService; @@ -77,11 +79,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url .anyRequest().authenticated() ) - .addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", jwtTokenProvider, userRepository, userAlarmSettingRepository), + .addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository), UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", googleLoginService, userRepository), + .addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository), UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(new AppleLoginFilter("/oauth2/apple/login", appleLoginService, userRepository), + .addFilterBefore(new AppleLoginFilter("/oauth2/apple/login", objectMapper, validator, appleLoginService, userRepository), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(customJsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class) .addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonUsernamePasswordAuthenticationFilter.class); @@ -121,7 +123,7 @@ public LoginFailureHandler loginFailureHandler() { @Bean public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordAuthenticationFilter() { CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordLoginFilter - = new CustomJsonUsernamePasswordAuthenticationFilter(objectMapper); + = new CustomJsonUsernamePasswordAuthenticationFilter(objectMapper, validator); customJsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager()); customJsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessHandler()); customJsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler()); diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java index a656df31..d4a46b1f 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java @@ -10,13 +10,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @RestController @RequiredArgsConstructor public class AlarmController { @@ -60,7 +59,7 @@ public ResponseEntity> getAlarmSetting @PatchMapping("/users/me/alarm-settings") public ResponseEntity> patchAlarmSettings( HttpServletRequest request, - @RequestBody Map requestBody) { + @Valid @RequestBody AlarmSettingsPatchDto requestBody) { Long userId = userAuthService.getUserIdFromToken(request); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponseForm.success(alarmService.patchAlarmSettings(userId, requestBody))); @@ -87,7 +86,7 @@ public ResponseEntity> patchAlarmSetti @PutMapping("/users/me/devices/current") public ResponseEntity> registerCurrentDevice( HttpServletRequest request, - @RequestBody AlarmDeviceCurrentRequestDto requestDto) { + @Valid @RequestBody AlarmDeviceCurrentRequestDto requestDto) { Long userId = userAuthService.getUserIdFromToken(request); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponseForm.success(alarmService.registerCurrentDevice( @@ -117,7 +116,7 @@ public ResponseEntity> registerCu @DeleteMapping("/users/me/devices/current") public ResponseEntity> unregisterCurrentDevice( HttpServletRequest request, - @RequestBody(required = false) AlarmDeviceUnregisterRequestDto requestDto) { + @Valid @RequestBody(required = false) AlarmDeviceUnregisterRequestDto requestDto) { Long userId = userAuthService.getUserIdFromToken(request); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponseForm.success(alarmService.unregisterCurrentDevice( @@ -147,7 +146,7 @@ public ResponseEntity> unregis @PostMapping("/users/me/alarm-status") public ResponseEntity> reportAlarmStatus( HttpServletRequest request, - @RequestBody AlarmStatusReportRequestDto requestDto) { + @Valid @RequestBody AlarmStatusReportRequestDto requestDto) { Long userId = userAuthService.getUserIdFromToken(request); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponseForm.success(alarmService.reportAlarmStatus( diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/FeedbackController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/FeedbackController.java index ff21ce8d..b91d019e 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/FeedbackController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/FeedbackController.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -47,7 +48,7 @@ public class FeedbackController { @ApiResponse(responseCode = "4XX", description = "피드백 저장 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PostMapping("") - public ResponseEntity> saveFeedback(HttpServletRequest request, @RequestBody FeedbackAddDto feedbackAddDto) { + public ResponseEntity> saveFeedback(HttpServletRequest request, @Valid @RequestBody FeedbackAddDto feedbackAddDto) { Long userId = userAuthService.getUserIdFromToken(request); feedbackService.saveFeedback(userId, feedbackAddDto); @@ -55,4 +56,4 @@ public ResponseEntity> saveFeedback(HttpServletRequest reques String message = "피드백이 성공적으로 저장되었습니다!"; return ResponseEntity.ok(ApiResponseForm.success(null, message)); } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java index 1e931f1e..cf338152 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -47,7 +48,7 @@ public class FirebaseTokenController { @ApiResponse(responseCode = "4XX", description = "FCM 토큰 저장 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PostMapping("") - public ResponseEntity> registerFirebaseToken(HttpServletRequest request, @RequestBody FirebaseTokenAddDto firebaseTokenAddDto) { + public ResponseEntity> registerFirebaseToken(HttpServletRequest request, @Valid @RequestBody FirebaseTokenAddDto firebaseTokenAddDto) { Long userId = userAuthService.getUserIdFromToken(request); firebaseTokenService.registerFirebaseToken( diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/FriendShipController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/FriendShipController.java index c4d86614..c175548a 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/FriendShipController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/FriendShipController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -85,10 +86,10 @@ public ResponseEntity> createFrien @ApiResponse(responseCode = "4XX", description = "친구추가 요청자 조회 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @GetMapping("/{uuid}/requests") // 친구 추가 요청자 조회 - public ResponseEntity> getFriendShipRequester(HttpServletRequest request, @PathVariable String uuid) { + public ResponseEntity> getFriendShipRequester(HttpServletRequest request, @PathVariable UUID uuid) { Long userId = userAuthService.getUserIdFromToken(request); - User requester = friendShipService.getFriendShipRequester(userId, UUID.fromString(uuid)); + User requester = friendShipService.getFriendShipRequester(userId, uuid); GetFriendshipRequesterResponse getFriendshipRequesterResponse = GetFriendshipRequesterResponse.builder() .requesterId(requester.getId()) .requesterName(requester.getName()) @@ -122,10 +123,10 @@ public ResponseEntity> getFriend @ApiResponse(responseCode = "4XX", description = "친구추가 수락상태 업데이트 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PostMapping("/{uuid}/approve") // 친구 추가 요청 수락 - public ResponseEntity> updateAcceptStatus(HttpServletRequest request, @PathVariable String uuid, @RequestBody UpdateAcceptStatusDto updateAcceptStatusDto) { + public ResponseEntity> updateAcceptStatus(HttpServletRequest request, @PathVariable UUID uuid, @Valid @RequestBody UpdateAcceptStatusDto updateAcceptStatusDto) { Long userId = userAuthService.getUserIdFromToken(request); - friendShipService.updateAcceptStatus(userId, UUID.fromString(uuid), updateAcceptStatusDto.getAcceptStatus()); + friendShipService.updateAcceptStatus(userId, uuid, updateAcceptStatusDto.getAcceptStatus()); String status = updateAcceptStatusDto.getAcceptStatus().equals("ACCEPTED") ? "수락" : "거절"; String message = "친구추가 요청 " + status + " 성공"; diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationScheduleController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationScheduleController.java index 199e119a..fe5a5d29 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationScheduleController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationScheduleController.java @@ -11,9 +11,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -22,6 +25,7 @@ @RestController @RequestMapping("/schedules") @RequiredArgsConstructor +@Validated public class PreparationScheduleController { private final PreparationScheduleService preparationScheduleService; @@ -45,7 +49,7 @@ public class PreparationScheduleController { @ApiResponse(responseCode = "4XX", description = "잘못된 요청", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PostMapping("/{scheduleId}/preparations") - public ResponseEntity> createPreparationSchedule(HttpServletRequest request, @Parameter(description = "스케줄 ID (UUID 형식)", required = true, example = "3fa85f64-5717-4562-b3fc-2c963f66afe5") @PathVariable UUID scheduleId, @RequestBody List preparationDtoList) { + public ResponseEntity> createPreparationSchedule(HttpServletRequest request, @Parameter(description = "스케줄 ID (UUID 형식)", required = true, example = "3fa85f64-5717-4562-b3fc-2c963f66afe5") @PathVariable UUID scheduleId, @NotEmpty(message = "준비과정은 하나 이상 필요합니다.") @RequestBody List<@Valid PreparationDto> preparationDtoList) { Long userId = userAuthService.getUserIdFromToken(request); preparationScheduleService.makePreparationSchedules(userId, scheduleId, preparationDtoList); @@ -70,7 +74,7 @@ public ResponseEntity> createPreparationSchedule(HttpServl @ApiResponse(responseCode = "4XX", description = "잘못된 요청", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PutMapping("/{scheduleId}/preparations") - public ResponseEntity> modifyPreparationSchedule(HttpServletRequest request, @Parameter(description = "스케줄 ID (UUID 형식)", required = true, example = "3fa85f64-5717-4562-b3fc-2c963f66afe5") @PathVariable UUID scheduleId, @RequestBody List preparationDtoList) { + public ResponseEntity> modifyPreparationSchedule(HttpServletRequest request, @Parameter(description = "스케줄 ID (UUID 형식)", required = true, example = "3fa85f64-5717-4562-b3fc-2c963f66afe5") @PathVariable UUID scheduleId, @NotEmpty(message = "준비과정은 하나 이상 필요합니다.") @RequestBody List<@Valid PreparationDto> preparationDtoList) { Long userId = userAuthService.getUserIdFromToken(request); preparationScheduleService.updatePreparationSchedules(userId, scheduleId, preparationDtoList); diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationUserController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationUserController.java index 418054e6..a826a80a 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationUserController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationUserController.java @@ -10,9 +10,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -20,6 +23,7 @@ @RestController @RequestMapping("/users") @RequiredArgsConstructor +@Validated public class PreparationUserController { @@ -44,7 +48,7 @@ public class PreparationUserController { @ApiResponse(responseCode = "4XX", description = "잘못된 요청", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PutMapping("/preparations") - public ResponseEntity> modifyPreparationUser(HttpServletRequest request, @RequestBody List preparationDtoList) { + public ResponseEntity> modifyPreparationUser(HttpServletRequest request, @NotEmpty(message = "준비과정은 하나 이상 필요합니다.") @RequestBody List<@Valid PreparationDto> preparationDtoList) { Long userId = userAuthService.getUserIdFromToken(request); preparationUserService.updatePreparationUsers(userId, preparationDtoList); diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java index 6c3c7fc8..6d2197cc 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; @@ -185,7 +186,7 @@ public ResponseEntity> deleteSchedule(HttpServletRequest r @ApiResponse(responseCode = "4XX", description = "잘못된 요청", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PutMapping("/{scheduleId}") - public ResponseEntity> modifySchedule(HttpServletRequest request, @PathVariable UUID scheduleId, @RequestBody ScheduleModDto scheduleModDto) { + public ResponseEntity> modifySchedule(HttpServletRequest request, @PathVariable UUID scheduleId, @Valid @RequestBody ScheduleModDto scheduleModDto) { Long userId = userAuthService.getUserIdFromToken(request); scheduleService.modifySchedule(userId, scheduleId, scheduleModDto); return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(null)); @@ -210,7 +211,7 @@ public ResponseEntity> modifySchedule(HttpServletRequest r @ApiResponse(responseCode = "4XX", description = "잘못된 요청", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PostMapping("") - public ResponseEntity> addSchedule(HttpServletRequest request, @RequestBody ScheduleAddDto scheduleAddDto) { + public ResponseEntity> addSchedule(HttpServletRequest request, @Valid @RequestBody ScheduleAddDto scheduleAddDto) { Long userId = userAuthService.getUserIdFromToken(request); scheduleService.addSchedule(scheduleAddDto, userId); return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(null)); @@ -300,7 +301,7 @@ public ResponseEntity>> getPreparation(Http public ResponseEntity> finishSchedule( HttpServletRequest request, @PathVariable UUID scheduleId, - @RequestBody FinishPreparationDto finishPreparationDto) { + @Valid @RequestBody FinishPreparationDto finishPreparationDto) { Long userId = userAuthService.getUserIdFromToken(request); scheduleService.finishSchedule(userId, scheduleId, finishPreparationDto); diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java index c4f687ba..97244a14 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -53,7 +54,7 @@ public class SocialAuthController { @ApiResponse(responseCode = "4XX", description = "실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(이메일이 이미 존재할 경우, 이름이 이미 존재할 경우 다르게 출력)"))) }) @PostMapping("/google/login") - public String googleRegisterOrLogin(@RequestBody OAuthGoogleUserDto oAuthGoogleUserDto, HttpServletResponse response) { + public String googleRegisterOrLogin(@Valid @RequestBody OAuthGoogleUserDto oAuthGoogleUserDto, HttpServletResponse response) { return "구글 로그인/회원가입 성공"; // 로그인 처리는 필터에서 적용 } @@ -80,7 +81,7 @@ public String googleRegisterOrLogin(@RequestBody OAuthGoogleUserDto oAuthGoogleU @ApiResponse(responseCode = "4XX", description = "실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(이메일이 이미 존재할 경우, 이름이 이미 존재할 경우 다르게 출력)"))) }) @PostMapping("/kakao/login") - public String kakaoRegisterOrLogin(@RequestBody OAuthKakaoUserDto oAuthKakaoUserDto, HttpServletResponse response) { + public String kakaoRegisterOrLogin(@Valid @RequestBody OAuthKakaoUserDto oAuthKakaoUserDto, HttpServletResponse response) { return "카카오 로그인/회원가입 성공"; // 로그인 처리는 필터에서 적용 } @@ -107,7 +108,7 @@ public String kakaoRegisterOrLogin(@RequestBody OAuthKakaoUserDto oAuthKakaoUser @ApiResponse(responseCode = "4XX", description = "실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(이메일이 이미 존재할 경우, 이름이 이미 존재할 경우 다르게 출력)"))) }) @PostMapping("/apple/login") - public String appleRegisterOrLogin(@RequestBody OAuthAppleRequestDto appleLoginRequestDto, HttpServletResponse response) { + public String appleRegisterOrLogin(@Valid @RequestBody OAuthAppleRequestDto appleLoginRequestDto, HttpServletResponse response) { return "애플 로그인/회원가입 성공"; } @@ -115,7 +116,7 @@ public String appleRegisterOrLogin(@RequestBody OAuthAppleRequestDto appleLoginR summary = "애플 소셜 로그인 회원탈퇴" ) @DeleteMapping("/apple/me") - public ResponseEntity> appleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) { + public ResponseEntity> appleDeleteUser(HttpServletRequest request, HttpServletResponse response, @Valid @RequestBody(required = false) FeedbackAddDto feedbackAddDto) { Long userId = userAuthService.getUserIdFromToken(request); log.info("userId: {}", userId); try { @@ -131,7 +132,7 @@ public ResponseEntity> appleDeleteUser(HttpServletRequest req summary = "구글 소셜 로그인 회원탈퇴" ) @DeleteMapping("/google/me") - public ResponseEntity> googleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) { + public ResponseEntity> googleDeleteUser(HttpServletRequest request, HttpServletResponse response, @Valid @RequestBody(required = false) FeedbackAddDto feedbackAddDto) { Long userId = userAuthService.getUserIdFromToken(request); log.info("userId: {}", userId); try { diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java index 13c4185b..c45375de 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java @@ -2,6 +2,7 @@ import devkor.ontime_back.dto.ChangePasswordDto; import devkor.ontime_back.dto.FeedbackAddDto; +import devkor.ontime_back.dto.LoginRequestDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; import devkor.ontime_back.entity.User; @@ -15,12 +16,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @RestController @RequiredArgsConstructor @@ -48,7 +48,7 @@ public class UserAuthController { @ApiResponse(responseCode = "4XX", description = "회원가입 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(이메일이 이미 존재할 경우, 이름이 이미 존재할 경우 다르게 출력)"))) }) @PostMapping("/sign-up") - public ResponseEntity> signUp(HttpServletRequest request, HttpServletResponse response, @RequestBody UserSignUpDto userSignUpDto) throws Exception { + public ResponseEntity> signUp(HttpServletRequest request, HttpServletResponse response, @Valid @RequestBody UserSignUpDto userSignUpDto) throws Exception { UserInfoResponse userSignUpResponse = userAuthService.signUp(request, response, userSignUpDto); String message = "회원가입이 성공적으로 완료되었습니다. 온보딩을 진행해주세요( /user/onboarding )"; return ResponseEntity.ok(ApiResponseForm.success(userSignUpResponse, message)); @@ -69,7 +69,7 @@ public String login( schema = @Schema(type = "object", example = "{\"email\": \"user@example.com\", \"password\": \"password123\"}") ) ) - @RequestBody Map loginRequest) { + @Valid @RequestBody LoginRequestDto loginRequest) { return "로그인 성공"; // 실제 로그인 처리는 Security 필터에서 수행 } @@ -97,7 +97,7 @@ public String login( @ApiResponse(responseCode = "4XX", description = "비밀번호 변경 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(기존 비밀번호가 틀린경우, 기존 비밀번호와 새 비밀번호가 같은 경우 다르게 출력)"))) }) @PutMapping("users/me/password") - public ResponseEntity> changePassword(HttpServletRequest request, @RequestBody ChangePasswordDto changePasswordDto) { + public ResponseEntity> changePassword(HttpServletRequest request, @Valid @RequestBody ChangePasswordDto changePasswordDto) { Long userId = userAuthService.getUserIdFromToken(request); userAuthService.changePassword(userId, changePasswordDto); String message = "비밀번호가 성공적으로 변경되었습니다!"; @@ -127,7 +127,7 @@ public ResponseEntity> changePassword(HttpServletRequest @ApiResponse(responseCode = "4XX", description = "계정 삭제 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(토큰 오류 제외 비즈니스 로직 오류는 없음)"))) }) @DeleteMapping("/users/me/delete") - public ResponseEntity> deleteUser(HttpServletRequest request, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) { + public ResponseEntity> deleteUser(HttpServletRequest request, @Valid @RequestBody(required = false) FeedbackAddDto feedbackAddDto) { Long userId = userAuthService.getUserIdFromToken(request); userAuthService.deleteUser(userId, feedbackAddDto); String message = "계정이 성공적으로 삭제되었습니다!"; diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/UserController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/UserController.java index 14818816..713751af 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/UserController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/UserController.java @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -109,7 +110,7 @@ public ResponseEntity> resetPunctualityScore(HttpServlet @ApiResponse(responseCode = "4XX", description = "사용자 여유시간 업데이트 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PutMapping("/me/spare-time") - public ResponseEntity> updateSetting(HttpServletRequest request, @RequestBody UpdateSpareTimeDto updateSpareTimeDto) { + public ResponseEntity> updateSetting(HttpServletRequest request, @Valid @RequestBody UpdateSpareTimeDto updateSpareTimeDto) { Long userId = userAuthService.getUserIdFromToken(request); userService.updateSpareTime(userId, updateSpareTimeDto); String message = "사용자 여유시간이 성공적으로 업데이트되었습니다!"; @@ -140,7 +141,7 @@ public ResponseEntity> updateSetting(HttpServletRequest reque @ApiResponse(responseCode = "4XX", description = "온보딩 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(토큰 오류 제외 비즈니스 로직 오류는 없음)"))) }) @PutMapping("/me/onboarding") - public ResponseEntity> addInfo(HttpServletRequest request, @RequestBody UserOnboardingDto userOnboardingDto) throws Exception { + public ResponseEntity> addInfo(HttpServletRequest request, @Valid @RequestBody UserOnboardingDto userOnboardingDto) throws Exception { Long userId = userAuthService.getUserIdFromToken(request); userService.onboarding(userId, userOnboardingDto); String message = "온보딩이 성공적으로 완료되었습니다!"; @@ -187,4 +188,3 @@ public ResponseEntity> getUserInfo(HttpServlet } } - diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/UserSettingController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/UserSettingController.java index c2c785f0..6d38db6e 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/UserSettingController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/UserSettingController.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PutMapping; @@ -47,7 +48,7 @@ public class UserSettingController { @ApiResponse(responseCode = "4XX", description = "사용자 앱 설정 업데이트 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PutMapping("") - public ResponseEntity> updateSetting(HttpServletRequest request, @RequestBody UserSettingUpdateDto userSettingUpdateDto) { + public ResponseEntity> updateSetting(HttpServletRequest request, @Valid @RequestBody UserSettingUpdateDto userSettingUpdateDto) { Long userId = userAuthService.getUserIdFromToken(request); userSettingService.updateSetting(userId, userSettingUpdateDto); diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentRequestDto.java index 22604f0e..bc97d97f 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentRequestDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentRequestDto.java @@ -1,5 +1,9 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,11 +14,22 @@ @NoArgsConstructor @AllArgsConstructor public class AlarmDeviceCurrentRequestDto { + @NotBlank(message = "deviceId는 필수입니다.") + @Pattern(regexp = "^[A-Za-z0-9._:-]{16,128}$", message = "deviceId 형식이 올바르지 않습니다.") private String deviceId; + @NotBlank(message = "platform은 필수입니다.") + @Pattern(regexp = "android|ios", message = "platform은 android 또는 ios만 가능합니다.") private String platform; + @Size(max = 128, message = "appVersion은 128자 이하여야 합니다.") private String appVersion; + @Size(max = 128, message = "osVersion은 128자 이하여야 합니다.") private String osVersion; + @NotNull(message = "supportsNativeAlarm은 필수입니다.") private Boolean supportsNativeAlarm; + @NotBlank(message = "nativeAlarmProvider는 필수입니다.") + @Pattern(regexp = "androidAlarmManager|iosAlarmKit|none", message = "nativeAlarmProvider 값이 올바르지 않습니다.") private String nativeAlarmProvider; + @NotBlank(message = "fallbackProvider는 필수입니다.") + @Pattern(regexp = "localNotification|none", message = "fallbackProvider 값이 올바르지 않습니다.") private String fallbackProvider; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterRequestDto.java index 49bdb879..8e63a5ed 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterRequestDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterRequestDto.java @@ -1,5 +1,6 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,5 +11,6 @@ @NoArgsConstructor @AllArgsConstructor public class AlarmDeviceUnregisterRequestDto { + @Pattern(regexp = "^[A-Za-z0-9._:-]{16,128}$", message = "deviceId 형식이 올바르지 않습니다.") private String deviceId; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsPatchDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsPatchDto.java new file mode 100644 index 00000000..9f7c38ae --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsPatchDto.java @@ -0,0 +1,69 @@ +package devkor.ontime_back.dto; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import jakarta.validation.constraints.AssertTrue; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; + +@Getter +@NoArgsConstructor +public class AlarmSettingsPatchDto { + private Object alarmsEnabled; + private Object defaultAlarmOffsetMinutes; + private final Map unknownFields = new HashMap<>(); + + @JsonAnySetter + public void addUnknownField(String name, Object value) { + unknownFields.put(name, value); + } + + @AssertTrue(message = "알 수 없는 알람 설정 필드입니다.") + public boolean isKnownFieldsOnly() { + return unknownFields.isEmpty(); + } + + @AssertTrue(message = "변경할 알람 설정을 하나 이상 입력해야 합니다.") + public boolean isNotEmpty() { + return alarmsEnabled != null || defaultAlarmOffsetMinutes != null || !unknownFields.isEmpty(); + } + + @AssertTrue(message = "alarmsEnabled는 boolean 값이어야 합니다.") + public boolean isAlarmsEnabledValid() { + return alarmsEnabled == null || alarmsEnabled instanceof Boolean; + } + + @AssertTrue(message = "defaultAlarmOffsetMinutes는 0 이상 1440 이하의 정수여야 합니다.") + public boolean isDefaultAlarmOffsetMinutesValid() { + Integer value = getDefaultAlarmOffsetMinutesValue(); + return defaultAlarmOffsetMinutes == null || (value != null && value >= 0 && value <= 1440); + } + + public Boolean getAlarmsEnabledValue() { + return alarmsEnabled instanceof Boolean value ? value : null; + } + + public Integer getDefaultAlarmOffsetMinutesValue() { + if (defaultAlarmOffsetMinutes instanceof Integer value) { + return value; + } + if (defaultAlarmOffsetMinutes instanceof Long value && value <= Integer.MAX_VALUE && value >= Integer.MIN_VALUE) { + return value.intValue(); + } + if (defaultAlarmOffsetMinutes instanceof Short value) { + return value.intValue(); + } + if (defaultAlarmOffsetMinutes instanceof Byte value) { + return value.intValue(); + } + if (defaultAlarmOffsetMinutes instanceof BigInteger value + && value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) <= 0 + && value.compareTo(BigInteger.valueOf(Integer.MIN_VALUE)) >= 0) { + return value.intValue(); + } + return null; + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusFailureDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusFailureDto.java index 1c5fbd66..2971601f 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusFailureDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusFailureDto.java @@ -1,5 +1,7 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,6 +12,8 @@ @NoArgsConstructor @AllArgsConstructor public class AlarmStatusFailureDto { + @Size(max = 36, message = "scheduleId는 36자 이하여야 합니다.") private String scheduleId; + @Pattern(regexp = "preparationLoadFailed|scheduleInvalid|platformError|unknown", message = "failure reason 값이 올바르지 않습니다.") private String reason; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportRequestDto.java index bb529921..ec834bf2 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportRequestDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportRequestDto.java @@ -1,5 +1,12 @@ package devkor.ontime_back.dto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,18 +20,46 @@ @NoArgsConstructor @AllArgsConstructor public class AlarmStatusReportRequestDto { + @NotBlank(message = "deviceId는 필수입니다.") + @Pattern(regexp = "^[A-Za-z0-9._:-]{16,128}$", message = "deviceId 형식이 올바르지 않습니다.") private String deviceId; + @NotNull(message = "reconciledAt은 필수입니다.") private OffsetDateTime reconciledAt; + @NotNull(message = "scheduleWindowStart는 필수입니다.") private LocalDateTime scheduleWindowStart; + @NotNull(message = "scheduleWindowEnd는 필수입니다.") private LocalDateTime scheduleWindowEnd; + @NotNull(message = "alarmCoverageStart는 필수입니다.") private LocalDateTime alarmCoverageStart; + @NotNull(message = "alarmCoverageEnd는 필수입니다.") private LocalDateTime alarmCoverageEnd; + @NotBlank(message = "status는 필수입니다.") + @Pattern(regexp = "armed|partial|disabled|permissionNeeded|unsupported|settingsUnavailable", message = "status 값이 올바르지 않습니다.") private String status; + @Pattern(regexp = "nativePermissionDenied|notificationPermissionDenied", message = "permissionIssue 값이 올바르지 않습니다.") private String permissionIssue; + @NotBlank(message = "nativeAlarmProvider는 필수입니다.") + @Pattern(regexp = "androidAlarmManager|iosAlarmKit|none", message = "nativeAlarmProvider 값이 올바르지 않습니다.") private String nativeAlarmProvider; + @NotBlank(message = "fallbackProvider는 필수입니다.") + @Pattern(regexp = "localNotification|none", message = "fallbackProvider 값이 올바르지 않습니다.") private String fallbackProvider; + @Min(value = 0, message = "armedScheduleCount는 0 이상이어야 합니다.") + @Max(value = 1440, message = "armedScheduleCount는 1440 이하여야 합니다.") private Integer armedScheduleCount; private List armedScheduleIds; + @Min(value = 0, message = "skippedScheduleCount는 0 이상이어야 합니다.") + @Max(value = 1440, message = "skippedScheduleCount는 1440 이하여야 합니다.") private Integer skippedScheduleCount; - private List failures; + private List<@Valid AlarmStatusFailureDto> failures; + + @AssertTrue(message = "scheduleWindowEnd는 scheduleWindowStart 이후여야 합니다.") + public boolean isScheduleWindowRangeValid() { + return scheduleWindowStart == null || scheduleWindowEnd == null || !scheduleWindowEnd.isBefore(scheduleWindowStart); + } + + @AssertTrue(message = "alarmCoverageEnd는 alarmCoverageStart 이후여야 합니다.") + public boolean isAlarmCoverageRangeValid() { + return alarmCoverageStart == null || alarmCoverageEnd == null || !alarmCoverageEnd.isBefore(alarmCoverageStart); + } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/ChangePasswordDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/ChangePasswordDto.java index 92c28bd9..123f1b43 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/ChangePasswordDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/ChangePasswordDto.java @@ -1,12 +1,22 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor public class ChangePasswordDto { + @NotBlank(message = "현재 비밀번호는 필수입니다.") + @Size(min = 8, max = 64, message = "현재 비밀번호는 8자 이상 64자 이하여야 합니다.") private String currentPassword; + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Size(min = 8, max = 64, message = "새 비밀번호는 8자 이상 64자 이하여야 합니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z\\d]).+$", message = "새 비밀번호는 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.") private String newPassword; @Builder public ChangePasswordDto(String currentPassword, String newPassword) { diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java index 54c658cb..ad9ec99d 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java @@ -1,5 +1,6 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,5 +14,6 @@ @AllArgsConstructor public class FeedbackAddDto { private UUID feedbackId; + @Size(max = 1000, message = "피드백은 1000자 이하여야 합니다.") private String message; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/FinishPreparationDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/FinishPreparationDto.java index b42e7815..f6de24d7 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/FinishPreparationDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/FinishPreparationDto.java @@ -1,12 +1,20 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import java.util.UUID; @Getter +@NoArgsConstructor public class FinishPreparationDto { private UUID scheduleId; + @NotNull(message = "지각 시간은 필수입니다.") + @Min(value = 0, message = "지각 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "지각 시간은 1440 이하여야 합니다.") private Integer latenessTime; @Builder public FinishPreparationDto(UUID scheduleId, Integer latenessTime) { diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/FirebaseTokenAddDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/FirebaseTokenAddDto.java index c4680868..e3a9d77c 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/FirebaseTokenAddDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/FirebaseTokenAddDto.java @@ -1,9 +1,18 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor public class FirebaseTokenAddDto { + @NotBlank(message = "FCM 토큰은 필수입니다.") + @Size(max = 4096, message = "FCM 토큰은 4096자 이하여야 합니다.") String firebaseToken; + @NotBlank(message = "deviceId는 필수입니다.") + @Pattern(regexp = "^[A-Za-z0-9._:-]{16,128}$", message = "deviceId 형식이 올바르지 않습니다.") String deviceId; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/LoginRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/LoginRequestDto.java new file mode 100644 index 00000000..be41dd80 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/LoginRequestDto.java @@ -0,0 +1,20 @@ +package devkor.ontime_back.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class LoginRequestDto { + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Size(max = 254, message = "이메일은 254자 이하여야 합니다.") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, max = 64, message = "비밀번호는 8자 이상 64자 이하여야 합니다.") + private String password; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthAppleRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthAppleRequestDto.java index 491c4f4f..2d265705 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthAppleRequestDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthAppleRequestDto.java @@ -1,11 +1,24 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor public class OAuthAppleRequestDto { + @NotBlank(message = "idToken은 필수입니다.") + @Size(max = 8192, message = "idToken은 8192자 이하여야 합니다.") private String idToken; + @NotBlank(message = "authCode는 필수입니다.") + @Size(max = 8192, message = "authCode는 8192자 이하여야 합니다.") private String authCode; + @NotBlank(message = "이름은 필수입니다.") + @Size(max = 50, message = "이름은 50자 이하여야 합니다.") private String fullName; + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Size(max = 254, message = "이메일은 254자 이하여야 합니다.") private String email; -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthGoogleRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthGoogleRequestDto.java index c7352760..0efcba30 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthGoogleRequestDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthGoogleRequestDto.java @@ -1,9 +1,16 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor public class OAuthGoogleRequestDto { + @NotBlank(message = "idToken은 필수입니다.") + @Size(max = 8192, message = "idToken은 8192자 이하여야 합니다.") private String idToken; + @Size(max = 8192, message = "refreshToken은 8192자 이하여야 합니다.") private String refreshToken; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthKakaoUserDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthKakaoUserDto.java index e3887343..45cba2a8 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthKakaoUserDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/OAuthKakaoUserDto.java @@ -1,19 +1,31 @@ package devkor.ontime_back.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Data; @Data public class OAuthKakaoUserDto { + @NotBlank(message = "카카오 사용자 ID는 필수입니다.") + @Size(max = 128, message = "카카오 사용자 ID는 128자 이하여야 합니다.") private String id; + @Valid + @NotNull(message = "프로필은 필수입니다.") private Profile profile; @Data public static class Profile { + @NotBlank(message = "닉네임은 필수입니다.") + @Size(max = 50, message = "닉네임은 50자 이하여야 합니다.") private String nickname; + @Size(max = 1000, message = "썸네일 URL은 1000자 이하여야 합니다.") private String thumbnailImageUrl; @JsonProperty("profile_image_url") + @Size(max = 1000, message = "프로필 이미지 URL은 1000자 이하여야 합니다.") private String profileImageUrl; private boolean isDefaultImage; private boolean isDefaultNickname; } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationDto.java index 34676a05..bbbf2f42 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationDto.java @@ -1,16 +1,29 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import java.util.UUID; @AllArgsConstructor +@NoArgsConstructor @Getter @Builder public class PreparationDto { + @NotNull(message = "preparationId는 필수입니다.") private UUID preparationId; + @NotBlank(message = "준비과정 이름은 필수입니다.") + @Size(max = 50, message = "준비과정 이름은 50자 이하여야 합니다.") private String preparationName; + @NotNull(message = "준비 시간은 필수입니다.") + @Min(value = 0, message = "준비 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "준비 시간은 1440 이하여야 합니다.") private Integer preparationTime; private UUID nextPreparationId; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java index 381fcb80..ef333c88 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java @@ -4,25 +4,47 @@ import devkor.ontime_back.entity.Place; import devkor.ontime_back.entity.Schedule; import devkor.ontime_back.entity.User; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.UUID; @Getter @Builder +@NoArgsConstructor @AllArgsConstructor public class ScheduleAddDto { + @NotNull(message = "scheduleId는 필수입니다.") private UUID scheduleId; + @NotNull(message = "placeId는 필수입니다.") private UUID placeId; + @NotBlank(message = "장소 이름은 필수입니다.") + @Size(max = 100, message = "장소 이름은 100자 이하여야 합니다.") private String placeName; + @NotBlank(message = "일정 이름은 필수입니다.") + @Size(max = 30, message = "일정 이름은 30자 이하여야 합니다.") private String scheduleName; + @NotNull(message = "이동 시간은 필수입니다.") + @Min(value = 0, message = "이동 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "이동 시간은 1440 이하여야 합니다.") private Integer moveTime; // 이동시간 + @NotNull(message = "일정 시간은 필수입니다.") + @FutureOrPresent(message = "일정 시간은 현재 또는 미래여야 합니다.") private LocalDateTime scheduleTime; // 약속시각 private Boolean isChange; // 변경여부 private Boolean isStarted; // 버튼누름여부 + @Min(value = 0, message = "여유 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "여유 시간은 1440 이하여야 합니다.") private Integer scheduleSpareTime; // 스케줄 별 여유시간 + @Size(max = 1000, message = "일정 메모는 1000자 이하여야 합니다.") private String scheduleNote; // 스케줄 별 주의사항 public Schedule toEntity(User user, Place place) { return Schedule.builder() diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleModDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleModDto.java index 02438260..e66631dc 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleModDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleModDto.java @@ -1,21 +1,42 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.UUID; @Getter @Builder +@NoArgsConstructor @AllArgsConstructor public class ScheduleModDto { + @NotNull(message = "placeId는 필수입니다.") private UUID placeId; + @NotBlank(message = "장소 이름은 필수입니다.") + @Size(max = 100, message = "장소 이름은 100자 이하여야 합니다.") private String placeName; + @NotBlank(message = "일정 이름은 필수입니다.") + @Size(max = 30, message = "일정 이름은 30자 이하여야 합니다.") private String scheduleName; + @NotNull(message = "이동 시간은 필수입니다.") + @Min(value = 0, message = "이동 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "이동 시간은 1440 이하여야 합니다.") private Integer moveTime; // 이동시간 + @NotNull(message = "일정 시간은 필수입니다.") private LocalDateTime scheduleTime; // 약속시각 + @Min(value = 0, message = "여유 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "여유 시간은 1440 이하여야 합니다.") private Integer scheduleSpareTime; // 스케줄 별 여유시간 + @Min(value = 0, message = "지각 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "지각 시간은 1440 이하여야 합니다.") private Integer latenessTime; + @Size(max = 1000, message = "일정 메모는 1000자 이하여야 합니다.") private String scheduleNote; // 스케줄 별 주의사항 } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/UpdateAcceptStatusDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/UpdateAcceptStatusDto.java index c19dbe22..990be9b5 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/UpdateAcceptStatusDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/UpdateAcceptStatusDto.java @@ -1,8 +1,14 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor public class UpdateAcceptStatusDto { + @NotBlank(message = "수락 상태는 필수입니다.") + @Pattern(regexp = "ACCEPTED|REJECTED", message = "수락 상태는 ACCEPTED 또는 REJECTED만 가능합니다.") private String acceptStatus; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/UpdateSpareTimeDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/UpdateSpareTimeDto.java index 792e0676..d8f97016 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/UpdateSpareTimeDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/UpdateSpareTimeDto.java @@ -1,5 +1,8 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,6 +10,9 @@ @Getter @NoArgsConstructor public class UpdateSpareTimeDto { + @NotNull(message = "여유 시간은 필수입니다.") + @Min(value = 0, message = "여유 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "여유 시간은 1440 이하여야 합니다.") private Integer newSpareTime; @Builder public UpdateSpareTimeDto(Integer newSpareTime) { diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/UserOnboardingDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/UserOnboardingDto.java index 5f8e7e70..46034f97 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/UserOnboardingDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/UserOnboardingDto.java @@ -1,13 +1,32 @@ package devkor.ontime_back.dto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import java.util.List; @Getter @Builder +@NoArgsConstructor public class UserOnboardingDto { + @NotNull(message = "여유 시간은 필수입니다.") + @Min(value = 0, message = "여유 시간은 0 이상이어야 합니다.") + @Max(value = 1440, message = "여유 시간은 1440 이하여야 합니다.") private Integer spareTime; + @Size(max = 1000, message = "주의사항은 1000자 이하여야 합니다.") private String note; - private List preparationList; + @NotEmpty(message = "준비과정은 하나 이상 필요합니다.") + private List<@Valid PreparationDto> preparationList; + + public UserOnboardingDto(Integer spareTime, String note, List preparationList) { + this.spareTime = spareTime; + this.note = note; + this.preparationList = preparationList; + } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/UserSettingUpdateDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/UserSettingUpdateDto.java index 1b87922b..2a7beed5 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/UserSettingUpdateDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/UserSettingUpdateDto.java @@ -1,5 +1,7 @@ package devkor.ontime_back.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,6 +9,8 @@ @NoArgsConstructor public class UserSettingUpdateDto { private Boolean isNotificationsEnabled; + @Min(value = 0, message = "소리 크기는 0 이상이어야 합니다.") + @Max(value = 100, message = "소리 크기는 100 이하여야 합니다.") private Integer soundVolume; private Boolean isPlayOnSpeaker; private Boolean is24HourFormat; diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/UserSignUpDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/UserSignUpDto.java index 27ced8e0..ba3f1b7f 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/UserSignUpDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/UserSignUpDto.java @@ -4,7 +4,10 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.UUID; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; @NoArgsConstructor @AllArgsConstructor @@ -12,7 +15,15 @@ @Builder public class UserSignUpDto { // 자체 로그인 회원 가입 API에 RequestBody로 사용할 UserSignUpDto를 생성 + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Size(max = 254, message = "이메일은 254자 이하여야 합니다.") private String email; + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, max = 64, message = "비밀번호는 8자 이상 64자 이하여야 합니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z\\d]).+$", message = "비밀번호는 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.") private String password; + @NotBlank(message = "이름은 필수입니다.") + @Size(max = 50, message = "이름은 50자 이하여야 합니다.") private String name; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/generallogin/filter/CustomJsonUsernamePasswordAuthenticationFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/generallogin/filter/CustomJsonUsernamePasswordAuthenticationFilter.java index e5f2cd14..385d34b8 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/generallogin/filter/CustomJsonUsernamePasswordAuthenticationFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/generallogin/filter/CustomJsonUsernamePasswordAuthenticationFilter.java @@ -1,6 +1,10 @@ package devkor.ontime_back.global.generallogin.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.dto.LoginRequestDto; +import devkor.ontime_back.response.ValidationErrorWriter; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -13,8 +17,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; +import java.util.Set; /** * 스프링 시큐리티의 폼 기반의 UsernamePasswordAuthenticationFilter를 참고하여 만든 커스텀 필터 @@ -27,16 +30,16 @@ public class CustomJsonUsernamePasswordAuthenticationFilter extends AbstractAuth private static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; // "/login"으로 오는 요청을 처리 private static final String HTTP_METHOD = "POST"; // 로그인 HTTP 메소드는 POST private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리 - private static final String USERNAME_KEY = "email"; // 회원 로그인 시 이메일 요청 JSON Key : "email" - private static final String PASSWORD_KEY = "password"; // 회원 로그인 시 비밀번호 요청 JSon Key : "password" private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); // "/login" + POST로 온 요청에 매칭된다. private final ObjectMapper objectMapper; + private final Validator validator; - public CustomJsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) { + public CustomJsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper, Validator validator) { super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 "login" + POST로 온 요청을 처리하기 위해 설정 this.objectMapper = objectMapper; + this.validator = validator; } /** @@ -63,15 +66,33 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType()); } - String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); - - Map usernamePasswordMap = objectMapper.readValue(messageBody, Map.class); + LoginRequestDto loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequestDto.class); + Set> violations = validator.validate(loginRequest); + if (!violations.isEmpty()) { + ValidationErrorWriter.write(response, objectMapper, violations); + throw new RequestValidationException(); + } - String email = usernamePasswordMap.get(USERNAME_KEY); - String password = usernamePasswordMap.get(PASSWORD_KEY); + String email = loginRequest.getEmail(); + String password = loginRequest.getPassword(); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);//principal 과 credentials 전달 return this.getAuthenticationManager().authenticate(authRequest); } -} \ No newline at end of file + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + if (failed instanceof RequestValidationException) { + return; + } + super.unsuccessfulAuthentication(request, response, failed); + } + + private static class RequestValidationException extends AuthenticationServiceException { + private RequestValidationException() { + super("Request validation failed"); + } + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilter.java index fc2922b9..901cb00d 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilter.java @@ -11,11 +11,14 @@ import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.global.jwt.JwtUtils; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.ValidationErrorWriter; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; @@ -41,16 +44,21 @@ import java.util.Date; import java.util.Map; import java.util.Optional; +import java.util.Set; @Slf4j public class AppleLoginFilter extends AbstractAuthenticationProcessingFilter { private final AppleLoginService appleLoginService; private final UserRepository userRepository; + private final ObjectMapper objectMapper; + private final Validator validator; private final RestTemplate restTemplate = new RestTemplate(); - public AppleLoginFilter(String defaultFilterProcessesUrl, AppleLoginService appleLoginService, UserRepository userRepository) { + public AppleLoginFilter(String defaultFilterProcessesUrl, ObjectMapper objectMapper, Validator validator, AppleLoginService appleLoginService, UserRepository userRepository) { super(defaultFilterProcessesUrl); + this.objectMapper = objectMapper; + this.validator = validator; this.appleLoginService = appleLoginService; this.userRepository = userRepository; } @@ -58,8 +66,12 @@ public AppleLoginFilter(String defaultFilterProcessesUrl, AppleLoginService appl @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException { - ObjectMapper objectMapper = new ObjectMapper(); OAuthAppleRequestDto oAuthAppleRequestDto = objectMapper.readValue(request.getInputStream(), OAuthAppleRequestDto.class); + Set> violations = validator.validate(oAuthAppleRequestDto); + if (!violations.isEmpty()) { + ValidationErrorWriter.write(response, objectMapper, violations); + throw new RequestValidationException(); + } try { // Apple Identity Token 검증 @@ -90,4 +102,20 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ } } + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException { + if (failed instanceof RequestValidationException) { + return; + } + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("{\"status\":\"error\", \"message\":\"Authentication failed\"}"); + } + + private static class RequestValidationException extends AuthenticationException { + private RequestValidationException() { + super("Request validation failed"); + } + } + } diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java index 45ab78a8..1a232748 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java @@ -7,10 +7,13 @@ import devkor.ontime_back.entity.SocialType; import devkor.ontime_back.entity.User; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.ValidationErrorWriter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.core.Authentication; @@ -21,6 +24,7 @@ import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @Slf4j @@ -29,10 +33,14 @@ public class GoogleLoginFilter extends AbstractAuthenticationProcessingFilter { private final UserRepository userRepository; private final GoogleLoginService googleLoginService; + private final ObjectMapper objectMapper; + private final Validator validator; - public GoogleLoginFilter(String defaultFilterProcessesUrl, GoogleLoginService googleLoginService, UserRepository userRepository) { + public GoogleLoginFilter(String defaultFilterProcessesUrl, ObjectMapper objectMapper, Validator validator, GoogleLoginService googleLoginService, UserRepository userRepository) { super(defaultFilterProcessesUrl); + this.objectMapper = objectMapper; + this.validator = validator; this.googleLoginService = googleLoginService; this.userRepository = userRepository; } @@ -40,8 +48,12 @@ public GoogleLoginFilter(String defaultFilterProcessesUrl, GoogleLoginService go @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { - ObjectMapper objectMapper = new ObjectMapper(); OAuthGoogleRequestDto oAuthGoogleRequestDto = objectMapper.readValue(request.getInputStream(), OAuthGoogleRequestDto.class); + Set> violations = validator.validate(oAuthGoogleRequestDto); + if (!violations.isEmpty()) { + ValidationErrorWriter.write(response, objectMapper, violations); + throw new RequestValidationException(); + } try { GoogleIdToken.Payload googlePayload = googleLoginService.verifyIdentityToken(oAuthGoogleRequestDto.getIdToken()); @@ -94,9 +106,18 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { + if (failed instanceof RequestValidationException) { + return; + } log.warn("구글 로그인 실패"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"status\":\"error\", \"message\":\"Authentication failed\"}"); } + private static class RequestValidationException extends AuthenticationException { + private RequestValidationException() { + super("Request validation failed"); + } + } + } diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java index a7e4e9c5..6c6e1056 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java @@ -10,10 +10,13 @@ import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.ValidationErrorWriter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -25,6 +28,7 @@ import java.io.IOException; import java.util.Collections; import java.util.Optional; +import java.util.Set; import java.util.UUID; @Slf4j @@ -33,12 +37,18 @@ public class KakaoLoginFilter extends AbstractAuthenticationProcessingFilter { private final UserRepository userRepository; private final UserAlarmSettingRepository userAlarmSettingRepository; private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private final Validator validator; public KakaoLoginFilter(String defaultFilterProcessesUrl, + ObjectMapper objectMapper, + Validator validator, JwtTokenProvider jwtTokenProvider, UserRepository userRepository, UserAlarmSettingRepository userAlarmSettingRepository) { super(defaultFilterProcessesUrl); + this.objectMapper = objectMapper; + this.validator = validator; this.jwtTokenProvider = jwtTokenProvider; this.userRepository = userRepository; this.userAlarmSettingRepository = userAlarmSettingRepository; @@ -48,8 +58,12 @@ public KakaoLoginFilter(String defaultFilterProcessesUrl, @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { - ObjectMapper objectMapper = new ObjectMapper(); OAuthKakaoUserDto oAuthKakaoUserDto = objectMapper.readValue(request.getInputStream(), OAuthKakaoUserDto.class); + Set> violations = validator.validate(oAuthKakaoUserDto); + if (!violations.isEmpty()) { + ValidationErrorWriter.write(response, objectMapper, violations); + throw new RequestValidationException(); + } Optional existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, oAuthKakaoUserDto.getId()); @@ -144,8 +158,17 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { + if (failed instanceof RequestValidationException) { + return; + } log.warn("카카오 로그인 실패"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"status\":\"error\", \"message\":\"Authentication failed\"}"); } + + private static class RequestValidationException extends AuthenticationException { + private RequestValidationException() { + super("Request validation failed"); + } + } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java b/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java index 3274ef92..89960db8 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java @@ -50,5 +50,8 @@ public static ApiResponseForm error(int code, String message) { return new ApiResponseForm<>("error", code, message, null); // 오류의 경우 data는 null로 처리 } -} + public static ApiResponseForm error(int code, String message, T data) { + return new ApiResponseForm<>("error", code, message, data); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/GlobalExceptionHandler.java b/ontime-back/src/main/java/devkor/ontime_back/response/GlobalExceptionHandler.java index b8c21592..3bb7186c 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/GlobalExceptionHandler.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/GlobalExceptionHandler.java @@ -2,12 +2,21 @@ import devkor.ontime_back.logging.RequestLogPolicy; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.Comparator; +import java.util.List; @Slf4j @@ -30,7 +39,7 @@ public ResponseEntity> handleInvalidTokenException(Invalid } @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex, HttpServletRequest request) { + public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex, HttpServletRequest request) { String requestId = RequestLogPolicy.resolveRequestId(request); log.error("[Error Log] requestId: {}, route: {}, method: {}, actor: {}, clientIp: {}, exception: {}, responseStatus: {}", @@ -38,7 +47,74 @@ public ResponseEntity> handleHttpMessageNotReadableExcepti return ResponseEntity .status(HttpStatus.BAD_REQUEST) .header(RequestLogPolicy.REQUEST_ID_HEADER, requestId) - .body(ApiResponseForm.error(400, "요청 형식이 올바르지 않습니다.")); + .body(validationError(List.of(new ValidationErrorResponse.FieldError("request", "요청 형식이 올바르지 않습니다.")))); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + List errors = ex.getBindingResult().getFieldErrors().stream() + .map(this::toValidationError) + .sorted(Comparator.comparing(ValidationErrorResponse.FieldError::field)) + .toList(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(validationError(errors)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException(ConstraintViolationException ex) { + List errors = ex.getConstraintViolations().stream() + .map(violation -> new ValidationErrorResponse.FieldError(fieldName(violation.getPropertyPath().toString()), violation.getMessage())) + .sorted(Comparator.comparing(ValidationErrorResponse.FieldError::field)) + .toList(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(validationError(errors)); + } + + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + List errors = ex.getAllValidationResults().stream() + .flatMap(result -> result.getResolvableErrors().stream() + .map(error -> new ValidationErrorResponse.FieldError(parameterName(result), error.getDefaultMessage()))) + .sorted(Comparator.comparing(ValidationErrorResponse.FieldError::field)) + .toList(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(validationError(errors)); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(validationError(List.of(new ValidationErrorResponse.FieldError(ex.getName(), "요청 값의 형식이 올바르지 않습니다.")))); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingServletRequestParameterException(MissingServletRequestParameterException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(validationError(List.of(new ValidationErrorResponse.FieldError(ex.getParameterName(), "필수 요청 값입니다.")))); + } + + private ValidationErrorResponse.FieldError toValidationError(FieldError fieldError) { + return new ValidationErrorResponse.FieldError(fieldError.getField(), fieldError.getDefaultMessage()); + } + + private ApiResponseForm validationError(List errors) { + return ValidationErrorWriter.validationError(errors); + } + + private String fieldName(String path) { + int lastDot = path.lastIndexOf('.'); + return lastDot >= 0 ? path.substring(lastDot + 1) : path; + } + + private String parameterName(org.springframework.validation.method.ParameterValidationResult result) { + String name = result.getMethodParameter().getParameterName(); + if (name == null || name.isBlank()) { + name = "request"; + } + if (result.getContainerIndex() != null) { + return name + "[" + result.getContainerIndex() + "]"; + } + if (result.getContainerKey() != null) { + return name + "[" + result.getContainerKey() + "]"; + } + return name; } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorResponse.java b/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorResponse.java new file mode 100644 index 00000000..9540e7f1 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorResponse.java @@ -0,0 +1,9 @@ +package devkor.ontime_back.response; + +import java.util.List; + +public record ValidationErrorResponse(List errors) { + + public record FieldError(String field, String message) { + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorWriter.java b/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorWriter.java new file mode 100644 index 00000000..ce33f81e --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorWriter.java @@ -0,0 +1,47 @@ +package devkor.ontime_back.response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.ConstraintViolation; +import org.springframework.http.MediaType; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +public final class ValidationErrorWriter { + + private ValidationErrorWriter() { + } + + public static void write(HttpServletResponse response, + ObjectMapper objectMapper, + Set> violations) throws IOException { + List errors = violations.stream() + .map(violation -> new ValidationErrorResponse.FieldError( + fieldName(violation), + violation.getMessage())) + .sorted(Comparator.comparing(ValidationErrorResponse.FieldError::field)) + .toList(); + + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), validationError(errors)); + } + + public static ApiResponseForm validationError(List errors) { + return ApiResponseForm.error( + ErrorCode.INVALID_INPUT.getCode(), + ErrorCode.INVALID_INPUT.getMessage(), + new ValidationErrorResponse(errors)); + } + + private static String fieldName(ConstraintViolation violation) { + String path = violation.getPropertyPath().toString(); + int lastDot = path.lastIndexOf('.'); + return lastDot >= 0 ? path.substring(lastDot + 1) : path; + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java b/ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java index 3fe02b34..c17fef10 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java @@ -55,6 +55,18 @@ public AlarmSettingsResponseDto getAlarmSettings(Long userId) { return toAlarmSettingsResponse(setting); } + @Transactional + public AlarmSettingsResponseDto patchAlarmSettings(Long userId, AlarmSettingsPatchDto requestBody) { + Map values = new HashMap<>(); + if (requestBody.getAlarmsEnabled() != null) { + values.put("alarmsEnabled", requestBody.getAlarmsEnabledValue()); + } + if (requestBody.getDefaultAlarmOffsetMinutes() != null) { + values.put("defaultAlarmOffsetMinutes", requestBody.getDefaultAlarmOffsetMinutesValue()); + } + return patchAlarmSettings(userId, values); + } + @Transactional public AlarmSettingsResponseDto patchAlarmSettings(Long userId, Map requestBody) { if (requestBody == null || requestBody.isEmpty()) { diff --git a/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java b/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java index aea74b73..a4381937 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java +++ b/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import devkor.ontime_back.controller.*; import devkor.ontime_back.global.generallogin.handler.LoginSuccessHandler; +import devkor.ontime_back.global.oauth.apple.AppleLoginService; +import devkor.ontime_back.global.oauth.google.GoogleLoginService; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.service.*; import org.springframework.beans.factory.annotation.Autowired; @@ -18,7 +20,12 @@ ScheduleController.class, FriendShipController.class, PreparationUserController.class, - PreparationScheduleController.class + PreparationScheduleController.class, + UserSettingController.class, + FeedbackController.class, + FirebaseTokenController.class, + AlarmController.class, + SocialAuthController.class } ) public abstract class ControllerTestSupport { @@ -47,6 +54,24 @@ public abstract class ControllerTestSupport { @MockBean protected PreparationScheduleService preparationScheduleService; + @MockBean + protected UserSettingService userSettingService; + + @MockBean + protected FeedbackService feedbackService; + + @MockBean + protected FirebaseTokenService firebaseTokenService; + + @MockBean + protected AlarmService alarmService; + + @MockBean + protected AppleLoginService appleLoginService; + + @MockBean + protected GoogleLoginService googleLoginService; + @MockBean protected UserRepository userRepository; diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/RequestValidationControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/RequestValidationControllerTest.java new file mode 100644 index 00000000..8e8e5ac5 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/RequestValidationControllerTest.java @@ -0,0 +1,201 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.ControllerTestSupport; +import devkor.ontime_back.TestSecurityConfig; +import devkor.ontime_back.response.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(TestSecurityConfig.class) +class RequestValidationControllerTest extends ControllerTestSupport { + + @Test + @DisplayName("회원가입 요청의 이메일과 약한 비밀번호를 검증한다") + void signUpValidationFailure() throws Exception { + mockMvc.perform(post("/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "email", "bad-email", + "password", "password123", + "name", "" + )))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verify(userAuthService, never()).signUp(any(), any(), any()); + } + + @Test + @DisplayName("일정 추가 요청의 음수 시간과 과거 시간을 검증한다") + void addScheduleValidationFailure() throws Exception { + mockMvc.perform(post("/schedules") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "scheduleId": "%s", + "placeId": "%s", + "placeName": "", + "scheduleName": "", + "moveTime": -1, + "scheduleTime": "2020-01-01T09:00:00", + "scheduleSpareTime": -1, + "scheduleNote": "note" + } + """.formatted(UUID.randomUUID(), UUID.randomUUID()))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verifyNoInteractions(scheduleService); + } + + @Test + @DisplayName("준비과정 목록 요청은 비어 있거나 잘못된 항목이면 거절한다") + void preparationListValidationFailure() throws Exception { + mockMvc.perform(put("/users/preparations") + .contentType(MediaType.APPLICATION_JSON) + .content("[]")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verifyNoInteractions(preparationUserService); + } + + @Test + @DisplayName("친구 요청 수락 상태는 허용된 값만 받는다") + void friendshipAcceptStatusValidationFailure() throws Exception { + mockMvc.perform(post("/friends/{uuid}/approve", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"acceptStatus\":\"PENDING\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verifyNoInteractions(friendshipService); + } + + @Test + @DisplayName("사용자 여유시간과 앱 설정 범위를 검증한다") + void userSettingValidationFailure() throws Exception { + mockMvc.perform(put("/users/me/spare-time") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"newSpareTime\":-1}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + mockMvc.perform(put("/users/me/settings") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"soundVolume\":101}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verifyNoInteractions(userService); + verifyNoInteractions(userSettingService); + } + + @Test + @DisplayName("피드백과 탈퇴 피드백의 길이를 검증한다") + void feedbackValidationFailure() throws Exception { + String oversizedMessage = "x".repeat(1001); + + mockMvc.perform(post("/feedback") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("message", oversizedMessage)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + mockMvc.perform(delete("/oauth2/google/me") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("message", oversizedMessage)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verifyNoInteractions(feedbackService); + verify(userAuthService, never()).deleteUser(any(), any()); + } + + @Test + @DisplayName("FCM 토큰과 디바이스 ID를 검증한다") + void firebaseTokenValidationFailure() throws Exception { + mockMvc.perform(post("/firebase-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firebaseToken\":\"\",\"deviceId\":\"short\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verifyNoInteractions(firebaseTokenService); + } + + @Test + @DisplayName("알람 설정 패치의 알 수 없는 필드와 타입을 검증한다") + void alarmSettingsValidationFailure() throws Exception { + mockMvc.perform(patch("/users/me/alarm-settings") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"alarmsEnabled\":\"true\",\"unknown\":1}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verifyNoInteractions(alarmService); + } + + @Test + @DisplayName("알람 상태 보고 요청의 날짜 범위와 enum 값을 검증한다") + void alarmStatusValidationFailure() throws Exception { + mockMvc.perform(post("/users/me/alarm-status") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "deviceId": "ios-device-000001", + "reconciledAt": "%s", + "scheduleWindowStart": "2026-05-08T10:00:00", + "scheduleWindowEnd": "2026-05-08T09:00:00", + "alarmCoverageStart": "2026-05-08T00:00:00", + "alarmCoverageEnd": "2026-05-08T01:00:00", + "status": "bad", + "nativeAlarmProvider": "iosAlarmKit", + "fallbackProvider": "localNotification" + } + """.formatted(LocalDateTime.now().toString() + "+09:00"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())) + .andExpect(jsonPath("$.data.errors").isArray()); + + verifyNoInteractions(alarmService); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java index 47f55717..0eb6757a 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java @@ -164,7 +164,7 @@ void modifySchedule_success() throws Exception { void addSchedule_success() throws Exception { // given Long userId = 1L; - LocalDateTime localDateTime = LocalDateTime.of(2024, 11, 25, 14, 0); + LocalDateTime localDateTime = LocalDateTime.now().plusDays(1); ScheduleAddDto scheduleAddDto = new ScheduleAddDto( UUID.randomUUID(), diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java index 672db798..8fa2d1ae 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java @@ -44,7 +44,7 @@ void signUp() throws Exception { UserSignUpDto request = UserSignUpDto.builder() .email("user@example.com") .name("junbeom") - .password("password123") + .password("password123!") .build(); UserInfoResponse userSignupResponse = UserInfoResponse.builder() @@ -76,7 +76,7 @@ void changePassword() throws Exception { // given ChangePasswordDto changePasswordDto = ChangePasswordDto.builder() .currentPassword("password1234") - .newPassword("password12345") + .newPassword("password12345!") .build(); when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/UserControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/UserControllerTest.java index 94bcc273..c3ac9332 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/UserControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/UserControllerTest.java @@ -97,11 +97,13 @@ void onboarding() throws Exception { PreparationDto preparationDto1 = PreparationDto.builder() .preparationId(UUID.randomUUID()) .preparationName("준비물") + .preparationTime(10) .build(); PreparationDto preparationDto2 = PreparationDto.builder() .preparationId(UUID.randomUUID()) .preparationName("준비물2") + .preparationTime(15) .build(); UserOnboardingDto userOnboardingDto = UserOnboardingDto.builder() @@ -126,4 +128,4 @@ void onboarding() throws Exception { .andExpect(jsonPath("$.message").value("온보딩이 성공적으로 완료되었습니다!")); } -} \ No newline at end of file +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/filter/CustomJsonUsernamePasswordAuthenticationFilterTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/filter/CustomJsonUsernamePasswordAuthenticationFilterTest.java index df249fa6..3c5b0792 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/filter/CustomJsonUsernamePasswordAuthenticationFilterTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/filter/CustomJsonUsernamePasswordAuthenticationFilterTest.java @@ -1,46 +1,43 @@ package devkor.ontime_back.global.generallogin.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validation; +import jakarta.validation.Validator; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; -@SpringBootTest @ExtendWith(MockitoExtension.class) class CustomJsonUsernamePasswordAuthenticationFilterTest { @Mock private AuthenticationManager authenticationManager; - @Autowired - private CustomJsonUsernamePasswordAuthenticationFilter filter; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); @Test @DisplayName("필터가 올바른 인증 요청을 처리한다") void testFilterProcessesCorrectRequest() throws Exception { // given String email = "user@example.com"; - String password = "password123"; + String password = "password123!"; MockHttpServletRequest request = new MockHttpServletRequest(); request.setContentType("application/json"); @@ -56,6 +53,7 @@ void testFilterProcessesCorrectRequest() throws Exception { when(authenticationManager.authenticate(authRequest)) .thenReturn(new UsernamePasswordAuthenticationToken(email, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + CustomJsonUsernamePasswordAuthenticationFilter filter = new CustomJsonUsernamePasswordAuthenticationFilter(objectMapper, validator); filter.setAuthenticationManager(authenticationManager); // when @@ -65,4 +63,26 @@ void testFilterProcessesCorrectRequest() throws Exception { assertThat(result).isNotNull(); assertThat(result.getPrincipal()).isEqualTo(email); } -} \ No newline at end of file + + @Test + @DisplayName("필터가 잘못된 로그인 요청을 400 validation 응답으로 처리한다") + void invalidLoginRequestReturnsValidationError() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContentType("application/json"); + request.setMethod("POST"); + request.setRequestURI("/login"); + request.setContent(objectMapper.writeValueAsBytes(Map.of("email", "not-email", "password", ""))); + + MockHttpServletResponse response = new MockHttpServletResponse(); + CustomJsonUsernamePasswordAuthenticationFilter filter = new CustomJsonUsernamePasswordAuthenticationFilter(objectMapper, validator); + filter.setAuthenticationManager(authenticationManager); + + assertThatThrownBy(() -> filter.attemptAuthentication(request, response)) + .isInstanceOf(AuthenticationException.class); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("\"status\":\"error\""); + assertThat(response.getContentAsString()).contains("\"code\":1002"); + assertThat(response.getContentAsString()).contains("\"errors\""); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java new file mode 100644 index 00000000..b9fbf815 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java @@ -0,0 +1,124 @@ +package devkor.ontime_back.global.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.global.oauth.apple.AppleLoginFilter; +import devkor.ontime_back.global.oauth.apple.AppleLoginService; +import devkor.ontime_back.global.oauth.google.GoogleLoginFilter; +import devkor.ontime_back.global.oauth.google.GoogleLoginService; +import devkor.ontime_back.global.oauth.kakao.KakaoLoginFilter; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserRepository; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.AuthenticationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class OAuthLoginFilterValidationTest { + + @Mock + private GoogleLoginService googleLoginService; + + @Mock + private AppleLoginService appleLoginService; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private UserRepository userRepository; + + @Mock + private UserAlarmSettingRepository userAlarmSettingRepository; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("구글 로그인 필터가 잘못된 요청을 400 validation 응답으로 처리한다") + void googleLoginFilterRejectsInvalidRequest() throws Exception { + GoogleLoginFilter filter = new GoogleLoginFilter( + "/oauth2/google/login", + objectMapper, + validator, + googleLoginService, + userRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThatThrownBy(() -> filter.attemptAuthentication( + request("/oauth2/google/login", "{}"), + response)) + .isInstanceOf(AuthenticationException.class); + + assertValidationResponse(response); + verifyNoInteractions(googleLoginService, userRepository); + } + + @Test + @DisplayName("카카오 로그인 필터가 잘못된 요청을 400 validation 응답으로 처리한다") + void kakaoLoginFilterRejectsInvalidRequest() throws Exception { + KakaoLoginFilter filter = new KakaoLoginFilter( + "/oauth2/kakao/login", + objectMapper, + validator, + jwtTokenProvider, + userRepository, + userAlarmSettingRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThatThrownBy(() -> filter.attemptAuthentication( + request("/oauth2/kakao/login", "{\"id\":\"\",\"profile\":{}}"), + response)) + .isInstanceOf(AuthenticationException.class); + + assertValidationResponse(response); + verifyNoInteractions(jwtTokenProvider, userRepository, userAlarmSettingRepository); + } + + @Test + @DisplayName("애플 로그인 필터가 잘못된 요청을 400 validation 응답으로 처리한다") + void appleLoginFilterRejectsInvalidRequest() throws Exception { + AppleLoginFilter filter = new AppleLoginFilter( + "/oauth2/apple/login", + objectMapper, + validator, + appleLoginService, + userRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThatThrownBy(() -> filter.attemptAuthentication( + request("/oauth2/apple/login", "{\"idToken\":\"\",\"authCode\":\"\",\"fullName\":\"\"}"), + response)) + .isInstanceOf(AuthenticationException.class); + + assertValidationResponse(response); + verifyNoInteractions(appleLoginService, userRepository); + } + + private MockHttpServletRequest request(String uri, String body) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContentType("application/json"); + request.setMethod("POST"); + request.setRequestURI(uri); + request.setContent(body.getBytes()); + return request; + } + + private void assertValidationResponse(MockHttpServletResponse response) throws Exception { + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("\"status\":\"error\""); + assertThat(response.getContentAsString()).contains("\"code\":1002"); + assertThat(response.getContentAsString()).contains("\"errors\""); + } +} diff --git a/ontime-back/src/test/resources/application.properties b/ontime-back/src/test/resources/application.properties new file mode 100644 index 00000000..9d0f890c --- /dev/null +++ b/ontime-back/src/test/resources/application.properties @@ -0,0 +1,29 @@ +spring.datasource.url=jdbc:h2:mem:ontime_test;MODE=MySQL;NON_KEYWORDS=USER;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1 +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driver-class-name=org.h2.Driver + +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false + +spring.flyway.enabled=false +spring.flyway.baseline-on-migrate=false + +jwt.secret.key=test_secret_key_for_ontime_back_application_tests_1234567890 +jwt.access.expiration=3600000 +jwt.refresh.expiration=1209600000 +jwt.access.header=Authorization +jwt.refresh.header=Authorization-refresh + +google.web.client-id=test-google-web-client-id +google.app.client-id=test-google-app-client-id + +apple.client.id=test-apple-client-id +apple.team.id=test-apple-team-id +apple.login.key=test-apple-key-id +apple.client.secret= +apple.private-key.base64= + +firebase.credentials.base64= +feature.apple-login.enabled=false