From 00ffde979fd370a5ed519bfe64ef9b3a203bebc8 Mon Sep 17 00:00:00 2001 From: mmihye Date: Wed, 23 Apr 2025 01:58:08 +0900 Subject: [PATCH] =?UTF-8?q?[#117]=20Feat=20:=20=EC=A0=91=EA=B7=BC=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 +- .../member/controller/MemberController.java | 2 + .../place/controller/PlaceController.java | 8 ++ .../plan/controller/PlanController.java | 5 + .../report/controller/ReportController.java | 2 + .../global/config/PublicEndpoint.java | 11 +++ .../global/config/SecurityConfig.java | 93 ++++++++++++------- .../global/security/jwt/JwtFilter.java | 1 - 8 files changed, 93 insertions(+), 33 deletions(-) create mode 100644 src/main/java/Journey/Together/global/config/PublicEndpoint.java diff --git a/build.gradle b/build.gradle index b4c7431..58e2de3 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,9 @@ dependencies { // Spring Security + OAuth implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework:spring-webflux' + implementation 'org.springframework.boot:spring-boot-starter-web' + // AWS implementation 'com.amazonaws:aws-java-sdk-s3:1.12.661' diff --git a/src/main/java/Journey/Together/domain/member/controller/MemberController.java b/src/main/java/Journey/Together/domain/member/controller/MemberController.java index f299f78..33753d9 100644 --- a/src/main/java/Journey/Together/domain/member/controller/MemberController.java +++ b/src/main/java/Journey/Together/domain/member/controller/MemberController.java @@ -5,6 +5,7 @@ import Journey.Together.domain.member.dto.MemberRes; import Journey.Together.domain.member.service.MemberService; import Journey.Together.global.common.ApiResponse; +import Journey.Together.global.config.PublicEndpoint; import Journey.Together.global.exception.Success; import Journey.Together.global.security.PrincipalDetails; import io.swagger.v3.oas.annotations.tags.Tag; @@ -15,6 +16,7 @@ @RestController @RequiredArgsConstructor +@PublicEndpoint @RequestMapping("/v1/member") @Tag(name = "Member", description = "사용자 관련 API") public class MemberController { diff --git a/src/main/java/Journey/Together/domain/place/controller/PlaceController.java b/src/main/java/Journey/Together/domain/place/controller/PlaceController.java index 16a1980..f9c3093 100644 --- a/src/main/java/Journey/Together/domain/place/controller/PlaceController.java +++ b/src/main/java/Journey/Together/domain/place/controller/PlaceController.java @@ -11,6 +11,7 @@ import Journey.Together.domain.place.service.DataMigrationService; import Journey.Together.domain.place.service.PlaceService; import Journey.Together.global.common.ApiResponse; +import Journey.Together.global.config.PublicEndpoint; import Journey.Together.global.exception.Success; import Journey.Together.global.security.PrincipalDetails; import io.swagger.v3.oas.annotations.tags.Tag; @@ -39,6 +40,7 @@ public class PlaceController { private final PlaceService placeService; private final DataMigrationService dataMigrationService; + @PublicEndpoint @GetMapping("/main") public ApiResponse getMain( @RequestParam String areacode, @RequestParam String sigungucode) { @@ -51,6 +53,7 @@ public ApiResponse getPlaceDetail(@AuthenticationPrincipal Princ return ApiResponse.success(Success.GET_PLACE_DETAIL_SUCCESS, placeService.getPlaceDetail(principalDetails.getMember(), placeId)); } + @PublicEndpoint @GetMapping("guest/{placeId}") public ApiResponse getPlaceDetail(@PathVariable Long placeId){ return ApiResponse.success(Success.GET_PLACE_DETAIL_SUCCESS, placeService.getGeustPlaceDetail(placeId)); @@ -72,6 +75,8 @@ public ApiResponse getPlaceReview(@AuthenticationPrincipal Princ return ApiResponse.success(Success.GET_PLACE_REVIEW_LIST_SUCCESS, placeService.getReviews(principalDetails.getMember(), placeId, pageable)); } + + @PublicEndpoint @GetMapping("/review/guest/{placeId}") public ApiResponse getPlaceReview( @PathVariable Long placeId, @PageableDefault(size = 5,page = 0) Pageable pageable) { @@ -107,6 +112,7 @@ public ApiResponse updatePlaceMyReview( placeService.updateMyPlaceReview(principalDetails.getMember(),updateReviewDto,addImages,reviewId); return ApiResponse.success(Success.UPDATE_MY_PLACE_REVIEW_SUCCESS); } + @PublicEndpoint @GetMapping("/search") public ApiResponse searchPlaceList( @RequestParam(required = false) String category, @@ -120,6 +126,7 @@ public ApiResponse searchPlaceList( return ApiResponse.success(Success.SEARCH_PLACE_LIST_SUCCESS, placeService.searchPlaceList(category,query,disabilityType,detailFilter,areacode,sigungucode,arrange,pageable)); } + @PublicEndpoint @GetMapping("/search/map") public ApiResponse> searchPlaceList( @RequestParam(required = false) String category, @@ -133,6 +140,7 @@ public ApiResponse> searchPlaceList( return ApiResponse.success(Success.SEARCH_PLACE_LIST_SUCCESS, placeService.searchPlaceMap(category,disabilityType,detailFilter,arrange,minX,maxX,minY,maxY)); } + @PublicEndpoint @GetMapping("/search/autocomplete") public ApiResponse>> searchPlaceComplete( @RequestParam String query diff --git a/src/main/java/Journey/Together/domain/plan/controller/PlanController.java b/src/main/java/Journey/Together/domain/plan/controller/PlanController.java index 4ea9568..e06178c 100644 --- a/src/main/java/Journey/Together/domain/plan/controller/PlanController.java +++ b/src/main/java/Journey/Together/domain/plan/controller/PlanController.java @@ -3,6 +3,7 @@ import Journey.Together.domain.plan.dto.*; import Journey.Together.domain.plan.service.PlanService; import Journey.Together.global.common.ApiResponse; +import Journey.Together.global.config.PublicEndpoint; import Journey.Together.global.exception.Success; import Journey.Together.global.security.PrincipalDetails; import io.swagger.v3.oas.annotations.tags.Tag; @@ -49,6 +50,7 @@ public ApiResponse updatePlanIsPublic(@AuthenticationPrincipal PrincipalDetails return ApiResponse.success(Success.UPDATE_PLAN_SUCCESS,planService.updatePlanIsPublic(principalDetails.getMember(),planId)); } + @PublicEndpoint @GetMapping("/search") public ApiResponse searchPlace(@RequestParam String word, @PageableDefault(size = 6,page = 0) Pageable pageable){ return ApiResponse.success(Success.SEARCH_SUCCESS,planService.searchPlace(word,pageable)); @@ -77,11 +79,13 @@ public ApiResponse deletePlanReview(@AuthenticationPrincipal PrincipalDetails pr return ApiResponse.success(Success.DELETE_PLAN_REVIEW_SUCCESS); } + @PublicEndpoint @GetMapping("/guest/review/{plan_id}") public ApiResponse findPlanReviewGuest(@PathVariable("plan_id")Long planId){ return ApiResponse.success(Success.GET_REVIEW_SUCCESS,planService.findPlanReview(null,planId)); } + @PublicEndpoint @GetMapping("/open") public ApiResponse findOpenPlans(@PageableDefault(size = 6) Pageable pageable){ return ApiResponse.success(Success.SEARCH_SUCCESS,planService.findOpenPlans(pageable)); @@ -92,6 +96,7 @@ public ApiResponse findPalnDetailInfo(@AuthenticationPrincipal Pr return ApiResponse.success(Success.SEARCH_SUCCESS,planService.findPlanDetail(principalDetails.getMember(),planId)); } + @PublicEndpoint @GetMapping("/guest/detail/{plan_id}") public ApiResponse findPalnDetailInfo(@PathVariable("plan_id")Long planId){ return ApiResponse.success(Success.SEARCH_SUCCESS,planService.findPlanDetail(null,planId)); diff --git a/src/main/java/Journey/Together/domain/report/controller/ReportController.java b/src/main/java/Journey/Together/domain/report/controller/ReportController.java index 059f4ce..440109b 100644 --- a/src/main/java/Journey/Together/domain/report/controller/ReportController.java +++ b/src/main/java/Journey/Together/domain/report/controller/ReportController.java @@ -6,6 +6,7 @@ import Journey.Together.domain.report.enumerate.ReviewType; import Journey.Together.domain.report.service.ReportService; import Journey.Together.global.common.ApiResponse; +import Journey.Together.global.config.PublicEndpoint; import Journey.Together.global.exception.Success; import Journey.Together.global.security.PrincipalDetails; import io.swagger.v3.oas.annotations.tags.Tag; @@ -18,6 +19,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/v1/report") +@PublicEndpoint @Tag(name = "Report", description = "신고하기 관련 API") public class ReportController { private final ReportService reportService; diff --git a/src/main/java/Journey/Together/global/config/PublicEndpoint.java b/src/main/java/Journey/Together/global/config/PublicEndpoint.java new file mode 100644 index 0000000..4c01152 --- /dev/null +++ b/src/main/java/Journey/Together/global/config/PublicEndpoint.java @@ -0,0 +1,11 @@ +package Journey.Together.global.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface PublicEndpoint { +} diff --git a/src/main/java/Journey/Together/global/config/SecurityConfig.java b/src/main/java/Journey/Together/global/config/SecurityConfig.java index 1feb630..e1f02cc 100644 --- a/src/main/java/Journey/Together/global/config/SecurityConfig.java +++ b/src/main/java/Journey/Together/global/config/SecurityConfig.java @@ -5,6 +5,8 @@ import Journey.Together.global.security.jwt.JwtAuthenticationEntryPoint; import Journey.Together.global.security.jwt.JwtFilter; import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; @@ -19,67 +21,83 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPattern; +import java.lang.reflect.Method; import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; @Configuration @EnableWebSecurity -@RequiredArgsConstructor public class SecurityConfig { private final JwtFilter jwtFilter; private final ExceptionFilter exceptionFilter; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final RequestMappingHandlerMapping handlerMapping; + + public SecurityConfig( + JwtFilter jwtFilter, + ExceptionFilter exceptionFilter, + JwtAccessDeniedHandler jwtAccessDeniedHandler, + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, + @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping + ) { + this.jwtFilter = jwtFilter; + this.exceptionFilter = exceptionFilter; + this.jwtAccessDeniedHandler = jwtAccessDeniedHandler; + this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; + this.handlerMapping = handlerMapping; + } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // CORS 허용, CSRF 비활성화 http.cors(Customizer.withDefaults()) - .csrf(AbstractHttpConfigurer::disable); + .csrf(AbstractHttpConfigurer::disable); - http.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));// Session 미사용 + http.sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));// Session 미사용 // httpBasic, httpFormLogin 비활성화 http.httpBasic(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable); + .formLogin(AbstractHttpConfigurer::disable); // JWT 관련 필터 설정 및 예외 처리 http.exceptionHandling((exceptionHandling) -> - exceptionHandling - .accessDeniedHandler(jwtAccessDeniedHandler) - .authenticationEntryPoint(jwtAuthenticationEntryPoint) + exceptionHandling + .accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint) ); http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(exceptionFilter, JwtFilter.class); // 요청 URI별 권한 설정 - http.authorizeHttpRequests((authorize) -> - // Swagger UI 외부 접속 허용 - authorize.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() - // 로그인 로직 접속 허용 - .requestMatchers("/v1/auth/**", "/oauth2/**", "/login.html").permitAll() - .requestMatchers("/actuator/**").permitAll() - .requestMatchers("/v1/member/**").authenticated() - .requestMatchers("/v1/place/main").permitAll() - .requestMatchers("/v1/place/review/guest/**").permitAll() - .requestMatchers("/v1/plan/guest/**").permitAll() - .requestMatchers("/v1/plan/open").permitAll() - .requestMatchers("/v1/plan/search").permitAll() - .requestMatchers("/v1/place/search").permitAll() - .requestMatchers("/v1/place/search/**").permitAll() - .requestMatchers("/v1/place/search/map").permitAll() - .requestMatchers("/v1/place/guest/**").permitAll() - .requestMatchers("/v1/report/**").permitAll() - - // 메인 페이지, 공고 페이지 등에 한해 인증 정보 없이 접근 가능 (추후 추가) - // 이외의 모든 요청은 인증 정보 필요 - .anyRequest().authenticated()); + http.authorizeHttpRequests(authorize -> { + // Swagger 등 기본 허용 + authorize.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll(); + authorize.requestMatchers("/v1/auth/**", "/oauth2/**", "/login.html").permitAll(); + authorize.requestMatchers("/actuator/**").permitAll(); + + // 동적으로 추출된 @PublicEndpoint 허용 처리 + for (String pattern : extractPublicUrls()) { + authorize.requestMatchers(pattern).permitAll(); + } + + // 나머지 인증 필요 + authorize.anyRequest().authenticated(); + }); + + // OAuth2 로그인 설정 http.oauth2Login(oauth2 -> oauth2 - .defaultSuccessUrl("/login-success") - .failureUrl("/login-failure")); + .defaultSuccessUrl("/login-success") + .failureUrl("/login-failure")); return http.build(); } @@ -105,8 +123,21 @@ CorsConfigurationSource corsConfigurationSource() { } @Bean - public PasswordEncoder passwordEncoder(){ + public PasswordEncoder passwordEncoder() { // 비밀번호 암호화 return new BCryptPasswordEncoder(); } + + private Set extractPublicUrls() { + return handlerMapping.getHandlerMethods().entrySet().stream() + .filter(entry -> { + Method method = entry.getValue().getMethod(); + return method.isAnnotationPresent(PublicEndpoint.class) + || method.getDeclaringClass().isAnnotationPresent(PublicEndpoint.class); + }) + .map(entry -> entry.getKey().getPathPatternsCondition()) + .filter(Objects::nonNull) + .flatMap(condition -> condition.getPatternValues().stream()) + .collect(Collectors.toSet()); + } } diff --git a/src/main/java/Journey/Together/global/security/jwt/JwtFilter.java b/src/main/java/Journey/Together/global/security/jwt/JwtFilter.java index b1a40df..0e5d7c5 100644 --- a/src/main/java/Journey/Together/global/security/jwt/JwtFilter.java +++ b/src/main/java/Journey/Together/global/security/jwt/JwtFilter.java @@ -25,7 +25,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse //jwt 유효성 검사를 하지않음 if ("/v1/auth/sign-in".equals(requestURI) || "/actuator/health".equals(requestURI) || "/v1/place/main".equals(requestURI)) { - filterChain.doFilter(request, response); return; }