diff --git a/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordRecommendedPostStepDefinitions.java b/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordRecommendedPostStepDefinitions.java index 2dfafcb0f..dfdcea129 100644 --- a/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordRecommendedPostStepDefinitions.java +++ b/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordRecommendedPostStepDefinitions.java @@ -46,7 +46,7 @@ public class KeywordRecommendedPostStepDefinitions extends AcceptanceSteps { public void 추천_포스트가_수정된다() { int statusCode = context.response.statusCode(); - assertThat(statusCode).isEqualTo(HttpStatus.OK.value()); + assertThat(statusCode).isEqualTo(HttpStatus.NO_CONTENT.value()); } @Then("추천 포스트가 삭제된다") diff --git a/backend/src/main/java/wooteco/prolog/login/ui/LoginConfig.java b/backend/src/main/java/wooteco/prolog/login/ui/LoginConfig.java index d3078a4ee..205ae061c 100644 --- a/backend/src/main/java/wooteco/prolog/login/ui/LoginConfig.java +++ b/backend/src/main/java/wooteco/prolog/login/ui/LoginConfig.java @@ -18,7 +18,7 @@ @Profile("!docu") public class LoginConfig implements WebMvcConfigurer { - private final static String BASE_PACKAGE = "wooteco.prolog"; + private static final String BASE_PACKAGE = "wooteco.prolog"; private final GithubLoginService githubLoginService; private final JwtTokenProvider jwtTokenProvider; @@ -29,7 +29,7 @@ public void addInterceptors(InterceptorRegistry registry) { AutoInterceptorPatternMaker mapper = new AutoInterceptorPatternMaker(BASE_PACKAGE, AuthMemberPrincipal.class); - registry.addInterceptor(new LoginInterceptor(githubLoginService)) + registry.addInterceptor(new LoginInterceptor(githubLoginService, mapper.extractLoginDetector())) .addPathPatterns(mapper.extractPatterns()); } diff --git a/backend/src/main/java/wooteco/prolog/login/ui/LoginInterceptor.java b/backend/src/main/java/wooteco/prolog/login/ui/LoginInterceptor.java index fe9b3afb8..150a8774a 100644 --- a/backend/src/main/java/wooteco/prolog/login/ui/LoginInterceptor.java +++ b/backend/src/main/java/wooteco/prolog/login/ui/LoginInterceptor.java @@ -1,58 +1,26 @@ package wooteco.prolog.login.ui; -import java.util.Objects; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; -import org.springframework.http.HttpMethod; import org.springframework.web.servlet.HandlerInterceptor; import wooteco.prolog.login.application.AuthorizationExtractor; import wooteco.prolog.login.application.GithubLoginService; +import wooteco.support.autoceptor.AuthenticationDetector; @AllArgsConstructor public class LoginInterceptor implements HandlerInterceptor { - private static final String ORIGIN = "Origin"; - private static final String ACCESS_REQUEST_METHOD = "Access-Control-Request-Method"; - private static final String ACCESS_REQUEST_HEADERS = "Access-Control-Request-Headers"; - private final GithubLoginService githubLoginService; + private final AuthenticationDetector authenticationDetector; @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, - Object handler) { - if (isPreflighted(request)) { - return true; - } - - if (HttpMethod.GET.matches(request.getMethod())) { + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!authenticationDetector.requireLogin(request)) { return true; } githubLoginService.validateToken(AuthorizationExtractor.extract(request)); return true; } - - private boolean isPreflighted(HttpServletRequest request) { - return isOptionsMethod(request) - && hasOrigin(request) - && hasRequestHeaders(request) - && hasRequestMethods(request); - } - - public boolean isOptionsMethod(HttpServletRequest request) { - return HttpMethod.OPTIONS.matches(request.getMethod()); - } - - public boolean hasOrigin(HttpServletRequest request) { - return Objects.nonNull(request.getHeader(ORIGIN)); - } - - public boolean hasRequestMethods(HttpServletRequest request) { - return Objects.nonNull(request.getHeader(ACCESS_REQUEST_METHOD)); - } - - public boolean hasRequestHeaders(HttpServletRequest request) { - return Objects.nonNull(request.getHeader(ACCESS_REQUEST_HEADERS)); - } } diff --git a/backend/src/main/java/wooteco/support/autoceptor/AuthenticationDetector.java b/backend/src/main/java/wooteco/support/autoceptor/AuthenticationDetector.java new file mode 100644 index 000000000..710a8e15c --- /dev/null +++ b/backend/src/main/java/wooteco/support/autoceptor/AuthenticationDetector.java @@ -0,0 +1,54 @@ +package wooteco.support.autoceptor; + + +import java.util.List; +import java.util.Objects; +import javax.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; + +@RequiredArgsConstructor +public class AuthenticationDetector { + + private static final String ORIGIN = "Origin"; + private static final String ACCESS_REQUEST_METHOD = "Access-Control-Request-Method"; + private static final String ACCESS_REQUEST_HEADERS = "Access-Control-Request-Headers"; + + private final List requireLoginPatterns; + + public boolean requireLogin(HttpServletRequest request) { + if (isPreflighted(request)) { + return false; + } + + if (HttpMethod.GET.matches(request.getMethod())) { + return false; + } + + return requireLoginPatterns.stream() + .anyMatch(pattern -> pattern.match(request)); + } + + private boolean isPreflighted(HttpServletRequest request) { + return isOptionsMethod(request) + && hasOrigin(request) + && hasRequestHeaders(request) + && hasRequestMethods(request); + } + + private boolean isOptionsMethod(HttpServletRequest request) { + return HttpMethod.OPTIONS.matches(request.getMethod()); + } + + private boolean hasOrigin(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(ORIGIN)); + } + + private boolean hasRequestMethods(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(ACCESS_REQUEST_METHOD)); + } + + private boolean hasRequestHeaders(HttpServletRequest request) { + return Objects.nonNull(request.getHeader(ACCESS_REQUEST_HEADERS)); + } +} diff --git a/backend/src/main/java/wooteco/support/autoceptor/AutoInterceptorPatternMaker.java b/backend/src/main/java/wooteco/support/autoceptor/AutoInterceptorPatternMaker.java index f21c7f62a..d984a149a 100644 --- a/backend/src/main/java/wooteco/support/autoceptor/AutoInterceptorPatternMaker.java +++ b/backend/src/main/java/wooteco/support/autoceptor/AutoInterceptorPatternMaker.java @@ -46,4 +46,8 @@ private MethodScanner createMethodScanner(List> targ public List extractPatterns() { return uriScanner.extractUri(); } + + public AuthenticationDetector extractLoginDetector() { + return uriScanner.extractLoginDetector(); + } } diff --git a/backend/src/main/java/wooteco/support/autoceptor/MethodPattern.java b/backend/src/main/java/wooteco/support/autoceptor/MethodPattern.java new file mode 100644 index 000000000..1c336abf6 --- /dev/null +++ b/backend/src/main/java/wooteco/support/autoceptor/MethodPattern.java @@ -0,0 +1,41 @@ +package wooteco.support.autoceptor; + +import java.util.regex.Pattern; +import javax.servlet.http.HttpServletRequest; +import org.springframework.http.HttpMethod; + +public class MethodPattern { + + private static final String INVALID_METHOD_PATTERN_MESSAGE = "uri 와 method 가 지정되지 않은 MethodPattern 이 있습니다. method : %s, uri : %s"; + + private final HttpMethod method; + private final Pattern pattern; + + public MethodPattern(HttpMethod method, String uri) { + validate(uri, method); + this.method = method; + this.pattern = convertToPattern(uri); + } + + private Pattern convertToPattern(String uri) { + String replace = uri.replace("*", "[^/]+"); + String regex = "^" + replace + "$"; + return Pattern.compile(regex); + } + + private void validate(String method, HttpMethod uri) { + if (uri == null || method == null) { + throw new IllegalArgumentException(String.format(INVALID_METHOD_PATTERN_MESSAGE, method, uri)); + } + } + + public boolean match(HttpServletRequest request) { + if (!pattern.matcher(request.getRequestURI()).matches()) { + return false; + } + if (!method.matches(request.getMethod())) { + return false; + } + return true; + } +} diff --git a/backend/src/main/java/wooteco/support/autoceptor/scanner/MappingAnnotation.java b/backend/src/main/java/wooteco/support/autoceptor/scanner/MappingAnnotation.java index dc5a7ade4..27f5669e8 100644 --- a/backend/src/main/java/wooteco/support/autoceptor/scanner/MappingAnnotation.java +++ b/backend/src/main/java/wooteco/support/autoceptor/scanner/MappingAnnotation.java @@ -3,44 +3,68 @@ import java.lang.annotation.Annotation; import java.lang.reflect.GenericDeclaration; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.function.Function; +import java.util.function.Supplier; +import org.springframework.http.HttpMethod; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; public enum MappingAnnotation { REQUEST_MAPPING(RequestMapping.class, - declaration -> Arrays.asList( - declaration.getAnnotation(RequestMapping.class).value())), + declaration -> Arrays.asList(declaration.getAnnotation(RequestMapping.class).value()), + () -> { + throw new IllegalArgumentException( + "RequestMapping 을 통해 LoginInterceptor 를 등록할 수 없습니다. @GetMapping 과 같은 형식으로 사용해주세요."); + }), GET(GetMapping.class, - declaration -> Arrays.asList(declaration.getAnnotation(GetMapping.class).value())), + declaration -> Arrays.asList(declaration.getAnnotation(GetMapping.class).value()), + () -> HttpMethod.GET), POST(PostMapping.class, - declaration -> Arrays.asList(declaration.getAnnotation(PostMapping.class).value())), + declaration -> Arrays.asList(declaration.getAnnotation(PostMapping.class).value()), + () -> HttpMethod.POST), DELETE(DeleteMapping.class, - declaration -> Arrays.asList(declaration.getAnnotation(DeleteMapping.class).value())), + declaration -> Arrays.asList(declaration.getAnnotation(DeleteMapping.class).value()), + () -> HttpMethod.DELETE), PUT(PutMapping.class, - declaration -> Arrays.asList(declaration.getAnnotation(PutMapping.class).value())); + declaration -> Arrays.asList(declaration.getAnnotation(PutMapping.class).value()), + () -> HttpMethod.PUT), + PATCH(PatchMapping.class, + declaration -> Arrays.asList(declaration.getAnnotation(PatchMapping.class).value()), + () -> HttpMethod.PATCH); private final Class typeToken; private final Function> values; + private final Supplier method; - MappingAnnotation( - Class typeToken, - Function> values - ) { + MappingAnnotation(Class typeToken, Function> values, + Supplier method) { this.typeToken = typeToken; this.values = values; + this.method = method; } public static List extractUriFrom(GenericDeclaration declaration) { - return Arrays.stream(values()) + List methodUris = Arrays.stream(values()) .filter(httpMethod -> declaration.isAnnotationPresent(httpMethod.typeToken)) - .map(httpMethods -> httpMethods.values.apply(declaration)) .findAny() - .orElse(Collections.emptyList()); + .map(httpMethod -> httpMethod.values.apply(declaration)) + .orElseGet(() -> Arrays.asList("")); + if(methodUris.isEmpty()){ + return Arrays.asList(""); + } + return methodUris; + } + + public static HttpMethod extractHttpMethod(GenericDeclaration declaration) { + MappingAnnotation annotation = Arrays.stream(values()) + .filter(httpMethod -> declaration.isAnnotationPresent(httpMethod.typeToken)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("해당 HttpMethod 는 로그인 처리에서 고려하지 않았습니다. 필요시 추가하시오")); + return annotation.method.get(); } } diff --git a/backend/src/main/java/wooteco/support/autoceptor/scanner/URIScanner.java b/backend/src/main/java/wooteco/support/autoceptor/scanner/URIScanner.java index 53c79ccf8..761498f2d 100644 --- a/backend/src/main/java/wooteco/support/autoceptor/scanner/URIScanner.java +++ b/backend/src/main/java/wooteco/support/autoceptor/scanner/URIScanner.java @@ -7,11 +7,15 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Stream; +import org.springframework.http.HttpMethod; import org.springframework.util.StringUtils; +import wooteco.support.autoceptor.AuthenticationDetector; +import wooteco.support.autoceptor.MethodPattern; public class URIScanner { @@ -82,4 +86,38 @@ private String createUri(String controllerUri, String methodUri) { .collect(joining("/")); } + + public AuthenticationDetector extractLoginDetector() { + List> controllers = controllerScanner.extractControllers(); + List requireLoginMethods = new ArrayList<>(); + + for (Class controller : controllers) { + List controllerUris = extractControllerUri(controller); + List methods = methodScanner.extractMethodAnnotatedOnParameter(controller); + requireLoginMethods.addAll(extractRequireLogin(controllerUris, methods)); + } + + return new AuthenticationDetector(requireLoginMethods); + } + + private List extractRequireLogin(List controllerUris, + List methods) { + if (methods.isEmpty()) { + return Collections.emptyList(); + } + return methods.stream() + .map(method -> extractRequireLoginFrom(method, controllerUris)) + .reduce((patternList1, patternList2) -> { + patternList1.addAll(patternList2); + return patternList1; + }) + .orElseThrow(() -> new IllegalArgumentException("해당 메서드로부터 uri 추출할 수 없습니다." + controllerUris)); + } + + private List extractRequireLoginFrom(Method method, List controllerUris) { + HttpMethod httpMethod = MappingAnnotation.extractHttpMethod(method); + return createUris(controllerUris, MappingAnnotation.extractUriFrom(method)).stream() + .map(methodUri -> new MethodPattern(httpMethod, methodUri)) + .collect(toList()); + } } diff --git a/backend/src/test/java/wooteco/support/autoceptor/MethodPatternTest.java b/backend/src/test/java/wooteco/support/autoceptor/MethodPatternTest.java new file mode 100644 index 000000000..07c9e899a --- /dev/null +++ b/backend/src/test/java/wooteco/support/autoceptor/MethodPatternTest.java @@ -0,0 +1,55 @@ +package wooteco.support.autoceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import javax.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class MethodPatternTest { + + @ParameterizedTest + @ValueSource(strings = {"/path1/param1/path2", "/path1/123123/path2"}) + void matching(String requestUri) { + // given + MethodPattern methodPattern = new MethodPattern(HttpMethod.GET, "/path1/*/path2"); + HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class); + given(mockRequest.getMethod()) + .willReturn(HttpMethod.GET.name()); + given(mockRequest.getRequestURI()) + .willReturn(requestUri); + + // when + boolean mathcing = methodPattern.match(mockRequest); + + // then + assertThat(mathcing).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"/path1/param1/path2/path3", "/path1/path2"}) + void notMatching(String requestUri) { + // given + MethodPattern methodPattern = new MethodPattern(HttpMethod.GET, "/path1/*/path2"); + HttpServletRequest mockRequest = Mockito.mock(HttpServletRequest.class); + given(mockRequest.getRequestURI()) + .willReturn(requestUri); + + // when + boolean mathcing = methodPattern.match(mockRequest); + + // then + assertThat(mathcing).isFalse(); + } +} diff --git a/backend/src/test/java/wooteco/support/autoceptor/URIScannerTest.java b/backend/src/test/java/wooteco/support/autoceptor/URIScannerTest.java index 6f1a8a1e0..a52616366 100644 --- a/backend/src/test/java/wooteco/support/autoceptor/URIScannerTest.java +++ b/backend/src/test/java/wooteco/support/autoceptor/URIScannerTest.java @@ -3,9 +3,11 @@ import static org.assertj.core.api.Assertions.assertThat; import java.lang.annotation.Annotation; +import java.util.Arrays; import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; import wooteco.prolog.login.domain.AuthMemberPrincipal; import wooteco.support.autoceptor.scanner.ControllerScanner; import wooteco.support.autoceptor.scanner.MethodScanner; @@ -17,17 +19,44 @@ class URIScannerTest { @Test void extractUriAndMethods() { + // given List> targetAnnotations = Collections.singletonList(AuthMemberPrincipal.class); ControllerScanner controllerScanner = new ControllerScanner(ControllerClass.class, RestControllerClass.class); MethodScanner methodScanner = new MethodScanner(targetAnnotations); + // when List uris = new URIScanner(controllerScanner, methodScanner).extractUri(); + // then assertThat(uris).containsOnly( "/api2/test", "/api2/test/*" ); } + + @Test + void extractLoginDetector() { + // given + List> targetAnnotations = + Collections.singletonList(AuthMemberPrincipal.class); + ControllerScanner controllerScanner = + new ControllerScanner(ControllerClass.class, RestControllerClass.class); + MethodScanner methodScanner = new MethodScanner(targetAnnotations); + + URIScanner uriScanner = new URIScanner(controllerScanner, methodScanner); + + List expectMethodPatterns = Arrays.asList( + new MethodPattern(HttpMethod.GET, "/api2/test"), + new MethodPattern(HttpMethod.DELETE, "/api2/test/*") + ); + + // when + AuthenticationDetector detector = uriScanner.extractLoginDetector(); + + // then + assertThat(detector).extracting("requireLoginPatterns").usingRecursiveComparison() + .isEqualTo(expectMethodPatterns); + } }