diff --git a/oversweet-api/build.gradle b/oversweet-api/build.gradle index b9d3efe..c283226 100644 --- a/oversweet-api/build.gradle +++ b/oversweet-api/build.gradle @@ -8,6 +8,9 @@ dependencies { // validation implementation 'org.springframework.boot:spring-boot-starter-validation' + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + // web implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/TestController.java b/oversweet-api/src/main/java/com/depromeet/oversweet/TestController.java index c679177..0da5c36 100644 --- a/oversweet-api/src/main/java/com/depromeet/oversweet/TestController.java +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/TestController.java @@ -20,7 +20,7 @@ @Tag(name = "Test", description = "테스트 API") @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/test") @RequiredArgsConstructor public class TestController { @@ -32,12 +32,12 @@ public String health() { return "health"; } - @GetMapping("test-db-connection") + @GetMapping("/db-connection") public String testDbConnection() { return franchisePureService.getFranchiseName(1L); } - @GetMapping("test-querydsl") + @GetMapping("/querydsl") public List testQuerydsl() { return franchisePureService.getFranchiseNames(List.of(1L, 2L)); } @@ -46,7 +46,7 @@ public List testQuerydsl() { * 1. OverSweet 을 상속 받은 TestException 검증 * 2. 정상 데이터 검증 */ - @GetMapping("/test-exception-handler/{id}") + @GetMapping("/exception-handler/{id}") public ResponseEntity> testExceptionHandler(@PathVariable("id") Long id) { if (id == 1) { throw new TestException(TEST_EXCEPTION); @@ -58,7 +58,7 @@ public ResponseEntity> testExceptionHandler(@P /** * Dto로 바인딩 되는 Validation 검증 (NotNull, NotBlank) */ - @GetMapping("/test-exception-handler2") + @GetMapping("/exception-handler2") public ResponseEntity> testExceptionHandler2(@RequestBody @Validated TestDataRequestDto testDataRequestDto) { TestDataResponseDto testDataResponseDto = new TestDataResponseDto(testDataRequestDto.getName()); return new ResponseEntity<>(DataResponse.of(HttpStatus.ACCEPTED, "응답 성공", testDataResponseDto), HttpStatus.ACCEPTED); diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/bookmark/controller/BookmarkController.java b/oversweet-api/src/main/java/com/depromeet/oversweet/bookmark/controller/BookmarkController.java index e0b27dd..7db867d 100644 --- a/oversweet-api/src/main/java/com/depromeet/oversweet/bookmark/controller/BookmarkController.java +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/bookmark/controller/BookmarkController.java @@ -8,14 +8,22 @@ import com.depromeet.oversweet.bookmark.service.FranchiseBookMarkSearchService; import com.depromeet.oversweet.response.DataResponse; import com.depromeet.oversweet.response.MessageResponse; +import com.depromeet.oversweet.security.service.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import static org.springframework.http.HttpStatus.OK; @@ -23,6 +31,7 @@ @RestController @RequestMapping("/api/v1/bookmarks") @RequiredArgsConstructor +@SecurityRequirement(name = "accessToken") public class BookmarkController { private final FranchiseBookMarkSearchService franchiseBookMarkSearchService; @@ -33,7 +42,6 @@ public class BookmarkController { /** * 유저가 즐겨 찾기한 프랜차이즈 목록 조회 - * TODO : 추후 로그인 기능 구현 후, 로그인한 유저의 ID를 받아와야 함 (ex. @AuthenticationPrincipal User user) */ @Operation(summary = "즐겨 찾기한 프랜차이즈 목록 조회", description = "유저가 즐겨 찾기한 프랜차이즈 목록을 조회한다.") @ApiResponses({ @@ -41,15 +49,16 @@ public class BookmarkController { responseCode = "200", description = "즐겨 찾기한 프랜차이즈 목록을 조회 성공") }) @GetMapping("/franchises") - public ResponseEntity> searchFranchiseBookMarked() { - FranchiseBookMarkedResponseDto responseDto = franchiseBookMarkSearchService.searchFranchiseBookMarked(100L); + public ResponseEntity> searchFranchiseBookMarked( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + FranchiseBookMarkedResponseDto responseDto = franchiseBookMarkSearchService.searchFranchiseBookMarked(userDetails.getId()); return ResponseEntity.ok(DataResponse.of(OK, "즐겨 찾기한 프랜차이즈 목록 조회 성공", responseDto)); } /** * 유저가 즐겨 찾기한 음료 목록 조회 - * TODO : 추후 로그인 기능 구현 후, 로그인한 유저의 ID를 받아와야 함 (ex. @AuthenticationPrincipal User user) */ @Operation(summary = "즐겨 찾기한 음료 목록 조회", description = "유저가 즐겨 찾기한 음료 목록을 조회한다.") @ApiResponses({ @@ -57,15 +66,16 @@ public ResponseEntity> searchFranch responseCode = "200", description = "즐겨 찾기한 음료 목록을 조회 성공") }) @GetMapping("/drinks") - public ResponseEntity> searchDrinkBookMarked() { - DrinkBookMarkedResponseDto responseDto = drinkBookMarkSearchService.searchDrinkBookMarked(100L); + public ResponseEntity> searchDrinkBookMarked( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + DrinkBookMarkedResponseDto responseDto = drinkBookMarkSearchService.searchDrinkBookMarked(userDetails.getId()); return ResponseEntity.ok(DataResponse.of(OK, "즐겨 찾기한 음료 목록 조회 성공", responseDto)); } /** * 유저가 특정 프랜차이즈를 즐겨 찾기 할 수 있다. - * TODO : 추후 로그인 기능 구현 후, 로그인한 유저의 ID를 받아와야 함 (ex. @AuthenticationPrincipal User user) */ @Operation(summary = "프랜차이즈 즐겨 찾기", description = "유저가 특정 프랜차이즈를 즐겨 찾기 할 수 있다.") @ApiResponses({ @@ -73,14 +83,15 @@ public ResponseEntity> searchDrinkBookM responseCode = "200", description = "프랜차이즈 즐겨 찾기 성공") }) @PostMapping("/franchises/{franchiseId}") - public ResponseEntity markFranchiseAsBookMark(@PathVariable @Parameter(description = "프랜차이즈 고유 Id") Long franchiseId) { - franchiseBookMarkRegisterService.register(100L, franchiseId); + public ResponseEntity markFranchiseAsBookMark(@PathVariable @Parameter(description = "프랜차이즈 고유 Id") Long franchiseId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + franchiseBookMarkRegisterService.register(userDetails.getId(), franchiseId); return ResponseEntity.ok(MessageResponse.of(OK, "프랜차이즈 즐겨 찾기 등록 성공")); } /** * 유저가 특정 음료를 즐겨 찾기 할 수 있다. - * TODO : 추후 로그인 기능 구현 후, 로그인한 유저의 ID를 받아와야 함 (ex. @AuthenticationPrincipal User user) */ @Operation(summary = "음료 즐겨 찾기", description = "유저가 특정 음료를 즐겨 찾기 할 수 있다.") @ApiResponses({ @@ -88,14 +99,15 @@ public ResponseEntity markFranchiseAsBookMark(@PathVariable @Pa responseCode = "200", description = "음료 즐겨 찾기 성공") }) @PostMapping("/drinks/{drinkId}") - public ResponseEntity markDrinkAsBookMark(@PathVariable @Parameter(description = "음료 고유 Id") Long drinkId) { - drinkBookMarkRegisterService.register(100L, drinkId); + public ResponseEntity markDrinkAsBookMark(@PathVariable @Parameter(description = "음료 고유 Id") Long drinkId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + drinkBookMarkRegisterService.register(userDetails.getId(), drinkId); return ResponseEntity.ok(MessageResponse.of(OK, "음료 즐겨 찾기 등록 성공")); } /** * 유저가 특정 프랜차이즈를 즐겨 찾기 해제 할 수 있다. - * TODO : 추후 로그인 기능 구현 후, 로그인한 유저의 ID를 받아와야 함 (ex. @AuthenticationPrincipal User user) */ @Operation(summary = "프랜차이즈 즐겨 찾기 해제", description = "유저가 특정 프랜차이즈를 즐겨 찾기 해제 할 수 있다.") @ApiResponses({ @@ -103,14 +115,15 @@ public ResponseEntity markDrinkAsBookMark(@PathVariable @Parame responseCode = "200", description = "프랜차이즈 즐겨 찾기 해제 성공") }) @DeleteMapping("/franchises/{franchiseId}") - public ResponseEntity> unMarkFranchiseAsBookMark(@PathVariable @Parameter(description = "프랜차이즈 고유 Id") Long franchiseId) { - FranchiseBookMarkedResponseDto response = franchiseBookMarkRegisterService.unregister(100L, franchiseId); + public ResponseEntity> unMarkFranchiseAsBookMark(@PathVariable @Parameter(description = "프랜차이즈 고유 Id") Long franchiseId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + FranchiseBookMarkedResponseDto response = franchiseBookMarkRegisterService.unregister(userDetails.getId(), franchiseId); return ResponseEntity.ok(DataResponse.of(OK, "프랜차이즈 즐겨 찾기 해제 성공", response)); } /** * 유저가 특정 음료를 즐겨 찾기 해제 할 수 있다. - * TODO : 추후 로그인 기능 구현 후, 로그인한 유저의 ID를 받아와야 함 (ex. @AuthenticationPrincipal User user) */ @Operation(summary = "음료 즐겨 찾기 해제", description = "유저가 특정 음료를 즐겨 찾기 해제 할 수 있다.") @ApiResponses({ @@ -118,11 +131,11 @@ public ResponseEntity> unMarkFranch responseCode = "200", description = "음료 즐겨 찾기 해제 성공") }) @DeleteMapping("/drinks/{drinkId}") - public ResponseEntity> unMarkDrinkAsBookMark(@PathVariable @Parameter(description = "음료 고유 Id") Long drinkId) { - DrinkBookMarkedResponseDto response = drinkBookMarkRegisterService.unregister(100L, drinkId); + public ResponseEntity> unMarkDrinkAsBookMark(@PathVariable @Parameter(description = "음료 고유 Id") Long drinkId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + DrinkBookMarkedResponseDto response = drinkBookMarkRegisterService.unregister(userDetails.getId(), drinkId); return ResponseEntity.ok(DataResponse.of(OK, "음료 즐겨 찾기 해제 성공", response)); } - - } diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/drink/controller/DrinkController.java b/oversweet-api/src/main/java/com/depromeet/oversweet/drink/controller/DrinkController.java index b6a7b70..d734ff4 100644 --- a/oversweet-api/src/main/java/com/depromeet/oversweet/drink/controller/DrinkController.java +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/drink/controller/DrinkController.java @@ -10,14 +10,17 @@ import com.depromeet.oversweet.drink.service.DrinkDetailSearchService; import com.depromeet.oversweet.drink.service.DrinkWeeklyStatisticsService; import com.depromeet.oversweet.response.DataResponse; +import com.depromeet.oversweet.security.service.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,6 +30,7 @@ @RestController @RequestMapping("/api/v1/drinks") @RequiredArgsConstructor +@SecurityRequirement(name = "accessToken") public class DrinkController { private final DrinkDailyStatisticsService drinkDailyStatisticsService; @@ -35,28 +39,29 @@ public class DrinkController { /** * 유저 하루(데일리) 먹은 당 통계 및 음료 목록 조회. - * 추후 로그인 기능 구현 후, 로그인한 유저의 ID를 받아와야 함 (ex. @AuthenticationPrincipal User user) */ @Operation(summary = "하루 당 섭취량 통계 조회", description = "유저가 하루 먹은 당 통계를 조회합니다.") @ApiResponses(@ApiResponse(responseCode = "200", description = "유저가 하루 먹은 당 통계 조회.")) @GetMapping("/statistics/daily") - public ResponseEntity> retrieveUserDailySugarStatistics() { - final DrinkDailySugarStatisticsResponse response = drinkDailyStatisticsService.retrieveUserDailySugarStatistics(100L); + public ResponseEntity> retrieveUserDailySugarStatistics( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + final DrinkDailySugarStatisticsResponse response = drinkDailyStatisticsService.retrieveUserDailySugarStatistics(userDetails.getId()); return ResponseEntity.ok() .body(DataResponse.of(HttpStatus.OK, "유저가 하루 먹은 당 통계 조회 성공", response)); } /** * 유저 주간 먹은 당 통계 정보 조회. - * 추후 로그인 기능 구현 후, 로그인한 유저의 ID를 받아와야 함 (ex. @AuthenticationPrincipal User user) */ @Operation(summary = "주간 당 섭취량 통계 조회", description = "유저의 주간 당 통계를 조회합니다.") @ApiResponses(@ApiResponse(responseCode = "200", description = "유저가 먹은 주간 당 통계 조회.")) @GetMapping("/statistics/weekly") public ResponseEntity> retrieveUserWeeklySugarStatistics( - @RequestBody @Valid final DrinkWeeklySugarDateRequest request + @RequestBody @Valid final DrinkWeeklySugarDateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - final DrinkWeeklySugarStatisticsResponse response = drinkWeeklyStatisticsService.retrieveUserWeeklySugarStatistics(100L, request); + final DrinkWeeklySugarStatisticsResponse response = drinkWeeklyStatisticsService.retrieveUserWeeklySugarStatistics(userDetails.getId(), request); return ResponseEntity.ok() .body(DataResponse.of(HttpStatus.OK, "유저가 먹은 주간 당 통계 조회 성공", response)); } diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/member/controller/MemberController.java b/oversweet-api/src/main/java/com/depromeet/oversweet/member/controller/MemberController.java index a63ea49..41f45fc 100644 --- a/oversweet-api/src/main/java/com/depromeet/oversweet/member/controller/MemberController.java +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/member/controller/MemberController.java @@ -7,8 +7,8 @@ import com.depromeet.oversweet.member.service.MemberFacade; import com.depromeet.oversweet.response.DataResponse; import com.depromeet.oversweet.response.MessageResponse; -import com.depromeet.oversweet.util.JwtTokenProvider; -import com.depromeet.oversweet.util.TokenResponse; +import com.depromeet.oversweet.security.jwt.JwtTokenProvider; +import com.depromeet.oversweet.security.jwt.TokenResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/record/controller/RecordController.java b/oversweet-api/src/main/java/com/depromeet/oversweet/record/controller/RecordController.java index 8df7bcc..c73291c 100644 --- a/oversweet-api/src/main/java/com/depromeet/oversweet/record/controller/RecordController.java +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/record/controller/RecordController.java @@ -4,14 +4,17 @@ import com.depromeet.oversweet.record.dto.response.DrinkRecordSaveResponse; import com.depromeet.oversweet.record.service.DrinkRecordSaveService; import com.depromeet.oversweet.response.DataResponse; +import com.depromeet.oversweet.security.service.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,6 +24,7 @@ @RestController @RequestMapping("/api/v1/record") @RequiredArgsConstructor +@SecurityRequirement(name = "accessToken") public class RecordController { private final DrinkRecordSaveService drinkRecordSaveService; @@ -33,9 +37,10 @@ public class RecordController { @ApiResponses(@ApiResponse(responseCode = "201", description = "마신 음료 당 기록 성공")) @PostMapping("/drink") public ResponseEntity> saveDrinkRecord( - @RequestBody @Valid final DrinkRecordSaveRequest drinkRecordSaveRequest + @RequestBody @Valid final DrinkRecordSaveRequest drinkRecordSaveRequest, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - DrinkRecordSaveResponse response = drinkRecordSaveService.saveDrinkRecord(100L, drinkRecordSaveRequest); + DrinkRecordSaveResponse response = drinkRecordSaveService.saveDrinkRecord(userDetails.getId(), drinkRecordSaveRequest); return ResponseEntity.ok().body(DataResponse.of(HttpStatus.CREATED, "마신 음료 당 기록 성공", response)); } diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/security/config/SecurityConfig.java b/oversweet-api/src/main/java/com/depromeet/oversweet/security/config/SecurityConfig.java new file mode 100644 index 0000000..478478b --- /dev/null +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/security/config/SecurityConfig.java @@ -0,0 +1,57 @@ +package com.depromeet.oversweet.security.config; + +import com.depromeet.oversweet.security.filter.ExceptionFilter; +import com.depromeet.oversweet.security.filter.JwtAuthenticationFilter; +import com.depromeet.oversweet.security.handler.JwtAuthenticationEntryPointHandler; +import com.depromeet.oversweet.security.jwt.JwtTokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(request -> request + .requestMatchers("/swagger-resources/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + .requestMatchers(HttpMethod.OPTIONS).permitAll() + .requestMatchers("/api/v1/test/**").permitAll() + .requestMatchers("/api/v1/members/**").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(handler -> handler + .authenticationEntryPoint(new JwtAuthenticationEntryPointHandler(objectMapper)) + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new ExceptionFilter(objectMapper), JwtAuthenticationFilter.class); + + return http.build(); + } + +} diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/security/filter/ExceptionFilter.java b/oversweet-api/src/main/java/com/depromeet/oversweet/security/filter/ExceptionFilter.java new file mode 100644 index 0000000..5eb0d6d --- /dev/null +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/security/filter/ExceptionFilter.java @@ -0,0 +1,50 @@ +package com.depromeet.oversweet.security.filter; + +import com.depromeet.oversweet.exception.ErrorCode; +import com.depromeet.oversweet.exception.ErrorResponse; +import com.depromeet.oversweet.exception.OverSweetException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExceptionFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws IOException { + try { + filterChain.doFilter(request, response); + } catch (OverSweetException e) { + writeErrorResponse(response, e.getErrorCode()); + } catch (Exception e) { + if (e.getCause() instanceof OverSweetException) { + writeErrorResponse(response, ((OverSweetException) e.getCause()).getErrorCode()); + } else { + log.error("error : {}", e); + writeErrorResponse(response, ErrorCode.INTERNAL_SERVER_ERROR); + } + } + } + + private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + ErrorResponse errorResponse = ErrorResponse.of(errorCode); + response.setStatus(errorCode.getStatus()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/security/filter/JwtAuthenticationFilter.java b/oversweet-api/src/main/java/com/depromeet/oversweet/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..7c63307 --- /dev/null +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,34 @@ +package com.depromeet.oversweet.security.filter; + +import com.depromeet.oversweet.security.jwt.JwtTokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Objects; + +@Slf4j +@AllArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String accessToken = jwtTokenProvider.resolveTokenFromRequest(request); + if (Objects.nonNull(accessToken) && jwtTokenProvider.isTokenValid(accessToken)) { + Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } + +} diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/security/handler/JwtAuthenticationEntryPointHandler.java b/oversweet-api/src/main/java/com/depromeet/oversweet/security/handler/JwtAuthenticationEntryPointHandler.java new file mode 100644 index 0000000..a65056d --- /dev/null +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/security/handler/JwtAuthenticationEntryPointHandler.java @@ -0,0 +1,36 @@ +package com.depromeet.oversweet.security.handler; + +import com.depromeet.oversweet.exception.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static com.depromeet.oversweet.exception.ErrorCode.ACCESS_DENIED; + +/** + * 유효한 자격증명을 제공하지 않고 접근하려 할 때, 403 Forbidden 에러를 리턴하는 클래스 + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPointHandler implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + ErrorResponse errorResponse = ErrorResponse.of(ACCESS_DENIED); + response.setStatus(ACCESS_DENIED.getStatus()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/security/jwt/JwtTokenProvider.java b/oversweet-api/src/main/java/com/depromeet/oversweet/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..4db38a4 --- /dev/null +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/security/jwt/JwtTokenProvider.java @@ -0,0 +1,104 @@ +package com.depromeet.oversweet.security.jwt; + +import com.depromeet.oversweet.exception.security.ExpiredTokenException; +import com.depromeet.oversweet.exception.security.InvalidTokenException; +import com.depromeet.oversweet.security.service.CustomUserDetails; +import com.depromeet.oversweet.security.service.CustomUserDetailsService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.regex.Pattern; + +import static com.depromeet.oversweet.exception.ErrorCode.EXPIRED_TOKEN_ERROR; +import static com.depromeet.oversweet.exception.ErrorCode.INVALID_TOKEN_ERROR; + +@Component +public class JwtTokenProvider { + + private static final Long ACCESS_TOKEN_VALID_TIME = 1000L * 60 * 60 * 24 * 365; // 1년 + private static final Long REFRESH_TOKEN_VALID_TIME = 1000L * 60 * 60 * 24 * 365; // 1년 + + private static Key secretKey; + private final CustomUserDetailsService customUserDetailsService; + + public JwtTokenProvider(@Value("${spring.jwt.secret}") String jwtSecretKey, + CustomUserDetailsService customUserDetailsService) { + this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecretKey)); + this.customUserDetailsService = customUserDetailsService; + } + + //토큰 생성 + public TokenResponse generateJwtToken(Long id) { + Claims claims = Jwts.claims().setSubject(String.valueOf(id)); + Date now = new Date(); + + String accessToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + + String refreshToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME)) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + + return TokenResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + //토큰 유효성 검사 + public Boolean isTokenValid(String token) { + Claims claims = getJws(token).getBody(); + return claims.getExpiration().getTime() >= new Date().getTime(); + } + + //토큰에서 Subject 추출(id) + private String getSubjectFromToken(String token) { + return getJws(token).getBody().getSubject(); + } + + private Jws getJws(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + } catch (ExpiredJwtException e) { + throw new ExpiredTokenException(EXPIRED_TOKEN_ERROR); + } catch (Exception e) { + throw new InvalidTokenException(INVALID_TOKEN_ERROR); + } + } + + public String resolveTokenFromRequest(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (authorization != null && Pattern.matches("^Bearer .*", authorization)) { + return authorization.replaceAll("^Bearer( )*", ""); + } + + return null; + } + + public Authentication getAuthentication(String accessToken) { + CustomUserDetails customUserDetails = customUserDetailsService.loadUserByUsername(getSubjectFromToken(accessToken)); + return new UsernamePasswordAuthenticationToken(customUserDetails, "", customUserDetails.getAuthorities()); + } +} diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/util/TokenResponse.java b/oversweet-api/src/main/java/com/depromeet/oversweet/security/jwt/TokenResponse.java similarity index 84% rename from oversweet-api/src/main/java/com/depromeet/oversweet/util/TokenResponse.java rename to oversweet-api/src/main/java/com/depromeet/oversweet/security/jwt/TokenResponse.java index 9e9c5f3..ab4dd14 100644 --- a/oversweet-api/src/main/java/com/depromeet/oversweet/util/TokenResponse.java +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/security/jwt/TokenResponse.java @@ -1,4 +1,4 @@ -package com.depromeet.oversweet.util; +package com.depromeet.oversweet.security.jwt; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/security/service/CustomUserDetails.java b/oversweet-api/src/main/java/com/depromeet/oversweet/security/service/CustomUserDetails.java new file mode 100644 index 0000000..a416bf3 --- /dev/null +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/security/service/CustomUserDetails.java @@ -0,0 +1,57 @@ +package com.depromeet.oversweet.security.service; + +import com.depromeet.oversweet.domain.member.entity.MemberEntity; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@AllArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final MemberEntity member; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_MEMBER")); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return String.valueOf(member.getId()); + } + + @Override + public boolean isAccountNonExpired() { + return false; + } + + @Override + public boolean isAccountNonLocked() { + return false; + } + + @Override + public boolean isCredentialsNonExpired() { + return false; + } + + @Override + public boolean isEnabled() { + return false; + } + + public Long getId() { + return member.getId(); + } +} diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/security/service/CustomUserDetailsService.java b/oversweet-api/src/main/java/com/depromeet/oversweet/security/service/CustomUserDetailsService.java new file mode 100644 index 0000000..ebd0204 --- /dev/null +++ b/oversweet-api/src/main/java/com/depromeet/oversweet/security/service/CustomUserDetailsService.java @@ -0,0 +1,21 @@ +package com.depromeet.oversweet.security.service; + +import com.depromeet.oversweet.domain.member.entity.MemberEntity; +import com.depromeet.oversweet.domain.member.repository.FindMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final FindMemberRepository findMemberRepository; + + @Override + public CustomUserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + MemberEntity member = findMemberRepository.findMemberById(Long.valueOf(username)); + return new CustomUserDetails(member); + } +} diff --git a/oversweet-api/src/main/java/com/depromeet/oversweet/util/JwtTokenProvider.java b/oversweet-api/src/main/java/com/depromeet/oversweet/util/JwtTokenProvider.java deleted file mode 100644 index 498cfb2..0000000 --- a/oversweet-api/src/main/java/com/depromeet/oversweet/util/JwtTokenProvider.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.depromeet.oversweet.util; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.security.Key; -import java.util.Date; - -@Component -public class JwtTokenProvider { - - private static final Long ACCESS_TOKEN_VALID_TIME = 1000L * 60 * 60 * 24 * 365; // 1년 - private static final Long REFRESH_TOKEN_VALID_TIME = 1000L * 60 * 60 * 24 * 365; // 1년 - - private static Key secretKey; - - public JwtTokenProvider(@Value("${spring.jwt.secret}") String jwtSecretKey) { - this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecretKey)); - } - - //토큰 생성 - public TokenResponse generateJwtToken(Long id) { - Claims claims = Jwts.claims().setSubject(String.valueOf(id)); - Date now = new Date(); - - String accessToken = Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) - .signWith(secretKey, SignatureAlgorithm.HS256) - .compact(); - - String refreshToken = Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME)) - .signWith(secretKey, SignatureAlgorithm.HS256) - .compact(); - - return TokenResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - } - - //토큰 유효성 검사 - public Boolean isTokenValid(String token) { - Claims claims = getClaimsFromToken(token); - return claims.getExpiration().getTime() >= new Date().getTime(); - } - - //토큰에서 Subject 추출(id) - private String getSubjectFromToken(String token) { - return getClaimsFromToken(token).getSubject(); - } - - private static Claims getClaimsFromToken(String token) { - return Jwts.parserBuilder() - .setSigningKey(secretKey) - .build() - .parseClaimsJws(token) - .getBody(); - } -} diff --git a/oversweet-common/src/main/java/com/depromeet/oversweet/exception/ErrorCode.java b/oversweet-common/src/main/java/com/depromeet/oversweet/exception/ErrorCode.java index 5775b6b..76ed5d1 100644 --- a/oversweet-common/src/main/java/com/depromeet/oversweet/exception/ErrorCode.java +++ b/oversweet-common/src/main/java/com/depromeet/oversweet/exception/ErrorCode.java @@ -34,6 +34,12 @@ public enum ErrorCode { // Redis (레디스) OVERSWEET_REDIS_JSON_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "레디스 직렬화/역직렬화 과정에서 오류가 발생했습니다."), + + // Security (jwt 토큰 및 Security) + EXPIRED_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, "S001", "토큰이 만료되었습니다."), + INVALID_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, "S002", "토큰이 유효하지 않습니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "S003", "토큰이 존재하지 않아 접근이 불가합니다."), + // Test TEST_EXCEPTION(HttpStatus.BAD_REQUEST, "T001", "테스트 에러"); diff --git a/oversweet-common/src/main/java/com/depromeet/oversweet/exception/security/ExpiredTokenException.java b/oversweet-common/src/main/java/com/depromeet/oversweet/exception/security/ExpiredTokenException.java new file mode 100644 index 0000000..bde8ba0 --- /dev/null +++ b/oversweet-common/src/main/java/com/depromeet/oversweet/exception/security/ExpiredTokenException.java @@ -0,0 +1,11 @@ +package com.depromeet.oversweet.exception.security; + +import com.depromeet.oversweet.exception.ErrorCode; +import com.depromeet.oversweet.exception.OverSweetException; + +public class ExpiredTokenException extends OverSweetException { + + public ExpiredTokenException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/oversweet-common/src/main/java/com/depromeet/oversweet/exception/security/InvalidTokenException.java b/oversweet-common/src/main/java/com/depromeet/oversweet/exception/security/InvalidTokenException.java new file mode 100644 index 0000000..a47b4da --- /dev/null +++ b/oversweet-common/src/main/java/com/depromeet/oversweet/exception/security/InvalidTokenException.java @@ -0,0 +1,11 @@ +package com.depromeet.oversweet.exception.security; + +import com.depromeet.oversweet.exception.ErrorCode; +import com.depromeet.oversweet.exception.OverSweetException; + +public class InvalidTokenException extends OverSweetException { + + public InvalidTokenException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/oversweet-common/src/main/java/com/depromeet/oversweet/swagger/OpenApiConfig.java b/oversweet-common/src/main/java/com/depromeet/oversweet/swagger/OpenApiConfig.java index bcf0354..1005a7c 100644 --- a/oversweet-common/src/main/java/com/depromeet/oversweet/swagger/OpenApiConfig.java +++ b/oversweet-common/src/main/java/com/depromeet/oversweet/swagger/OpenApiConfig.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -29,8 +30,19 @@ public OpenAPI springOpenAPI() { return new OpenAPI() .servers(List.of(serverLocal, serverProd)) - .components(new Components()) + .components(securitySetting()) .info(info); } + private Components securitySetting() { + return new Components() + .addSecuritySchemes("accessToken", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("Authorization") + .in(SecurityScheme.In.HEADER) + .name("Authorization")); + } + } \ No newline at end of file