diff --git a/apigateway/Dockerfile b/apigateway/Dockerfile new file mode 100644 index 0000000..ce1d5d0 --- /dev/null +++ b/apigateway/Dockerfile @@ -0,0 +1,15 @@ +# openjdk8 base image +FROM openjdk:8-jre-alpine + +# directory 생성 +RUN mkdir -p /usr/app/msa-attach-volume/messages +# jar 파일이 복사되는 위치 +ENV APP_HOME=/usr/app/ +# 작업 시작 위치 +WORKDIR $APP_HOME +# jar 파일 복사 +COPY build/libs/*.jar apigateway.jar +# application port +EXPOSE 8000 +# 실행 +CMD ["java", "-Dspring.profiles.active=${profile:default}", "-jar", "apigateway.jar"] diff --git a/apigateway/DockerfileK8s b/apigateway/DockerfileK8s new file mode 100644 index 0000000..d9b2e0c --- /dev/null +++ b/apigateway/DockerfileK8s @@ -0,0 +1,4 @@ +FROM openjdk:8-jre-alpine +WORKDIR /app/ +COPY build/libs/*.jar app.jar +CMD ["java", "-Dlogstash.url=logstash.default.svc.cluster.local:8088", "-jar", "app.jar"] diff --git a/apigateway/Jenkinsfile b/apigateway/Jenkinsfile new file mode 100644 index 0000000..988557c --- /dev/null +++ b/apigateway/Jenkinsfile @@ -0,0 +1,48 @@ +// https://github.com/jenkinsci/kubernetes-plugin +podTemplate( + name: "jenkins-slave", + serviceAccount: "jenkins", + containers: [ + containerTemplate(name: "docker", image: "docker", ttyEnabled: true, command: 'cat'), + containerTemplate(name: "kubectl", image: "lachlanevenson/k8s-kubectl:v1.17.5", ttyEnabled: true, command: 'cat') + ], + volumes: [ + hostPathVolume(mountPath: "/var/run/docker.sock", hostPath: "/var/run/docker.sock") + ] +) { + node(POD_LABEL) { + def now = java.time.LocalDateTime.now() + def projectId = "apigateway" // 프로젝트 아이디 + + // docker + def dockerhubCredentialsId = "username-password-dockerhub" // 젠킨스 도커허브 자격증명 + def dockerImage = "egovframe/egovframe-msa-edu-backend-${projectId}" // 도커허브 이미지 + def dockerImageTag = "latest" // 도커허브 이미지 태그 + + stage("git") { + checkout scm + } + + stage("gradle") { + sh "./gradlew wrap --gradle-version=7.3 --daemon" + sh "./gradlew bootJar" + } + + stage("docker") { + container("docker") { + withCredentials([usernamePassword(credentialsId: "${dockerhubCredentialsId}", passwordVariable: "password", usernameVariable: "username")]) { + sh "docker login -u $username -p $password" + sh "docker build -f DockerfileK8s -t ${dockerImage}:${dockerImageTag} ." + sh "docker push ${dockerImage}:${dockerImageTag}" + } + } + } + + stage( "kubernetes" ) { + container("kubectl") { + sh "kubectl rollout restart deployment ${projectId}-deployment" + sh "kubectl annotate deployment.apps/${projectId}-deployment kubernetes.io/change-cause='Image updated at ${now} by ${env.BUILD_TAG}.'" + } + } + } +} \ No newline at end of file diff --git a/apigateway/build.gradle b/apigateway/build.gradle new file mode 100644 index 0000000..3120e5e --- /dev/null +++ b/apigateway/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'org.springframework.boot' version '2.7.12' + id 'org.sonarqube' version '3.5.0.2730' + id 'io.spring.dependency-management' version '1.1.0' + id 'java' +} + +group = 'org.egovframe.cloud' +version = '1.0.0' +sourceCompatibility = '1.8' + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', '2021.0.7') + set('log4j2.version', '2.20.0') // log4j 보안 패치 +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.cloud:spring-cloud-starter-gateway' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + implementation 'org.springframework.cloud:spring-cloud-starter-config' // config + implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap' // config + implementation 'org.springframework.cloud:spring-cloud-starter-bus-amqp' // bus + implementation 'org.apache.tomcat.embed:tomcat-embed-el:9.0.73' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' // logstash logback + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + // Resilience4j 의존성 추가 + implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.0' + implementation 'io.github.resilience4j:resilience4j-reactor:1.7.0' + implementation 'io.micrometer:micrometer-registry-prometheus' + + implementation 'org.springdoc:springdoc-openapi-webflux-ui:1.7.0' + implementation 'com.github.ben-manes.caffeine:caffeine' + + // macOS용 Netty DNS 리졸버 네이티브 라이브러리 추가 + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.79.Final:osx-x86_64' + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.79.Final:osx-aarch_64' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +test { + useJUnitPlatform() +} diff --git a/apigateway/gradle/wrapper/gradle-wrapper.jar b/apigateway/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/apigateway/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apigateway/gradle/wrapper/gradle-wrapper.properties b/apigateway/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b1159fc --- /dev/null +++ b/apigateway/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apigateway/gradlew b/apigateway/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/apigateway/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/apigateway/gradlew.bat b/apigateway/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/apigateway/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/apigateway/manifest.yml b/apigateway/manifest.yml new file mode 100644 index 0000000..bbff6ae --- /dev/null +++ b/apigateway/manifest.yml @@ -0,0 +1,16 @@ +--- +applications: + - name: egov-apigateway # CF push 시 생성되는 이름 +# memory: 512M # 메모리 + instances: 1 # 인스턴스 수 + host: egov-apigateway # host 명으로 유일해야 함 + path: build/libs/apigateway-0.1.jar # build 후 생성된 jar 위치 + buildpack: java_buildpack # cf buildpacks 명령어로 java buildpack 이름 확인 + services: + - egov-discovery-provided-service # discovery service binding + env: + spring_profiles_active: cf + spring_cloud_config_uri: http://localhost:8888 + app_name: egov-apigateway # logstash custom app name + TZ: Asia/Seoul + JAVA_OPTS: -Xss349k diff --git a/apigateway/settings.gradle b/apigateway/settings.gradle new file mode 100644 index 0000000..ed34aee --- /dev/null +++ b/apigateway/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'apigateway' diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/ApigatewayApplication.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/ApigatewayApplication.java new file mode 100644 index 0000000..d6d315e --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/ApigatewayApplication.java @@ -0,0 +1,33 @@ +package org.egovframe.cloud.apigateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +/** + * org.egovframe.cloud.apigateway.ApigatewayApplication + *

+ * 게이트웨이 어플리케이션 클래스 + * Eureka Client 로 설정했기 때문에 Eureka Server 가 먼저 기동되어야 한다. + * + * @author 표준프레임워크센터 jaeyeolkim + * @version 1.0 + * @since 2021/06/30 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2021/06/30    jaeyeolkim  최초 생성
+ * 
+ */ +@EnableDiscoveryClient +@SpringBootApplication +public class ApigatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(ApigatewayApplication.class, args); + } + +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/api/MessageSourceApiController.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/api/MessageSourceApiController.java new file mode 100644 index 0000000..70a925c --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/api/MessageSourceApiController.java @@ -0,0 +1,43 @@ +package org.egovframe.cloud.apigateway.api; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Locale; + +/** + * org.egovframe.cloud.apigateway.api.MessageSourceApiController + *

+ * MessageSource 정상 확인을 위한 컨트롤러 + * + * @author 표준프레임워크센터 jaeyeolkim + * @version 1.0 + * @since 2021/08/10 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2021/08/10    jaeyeolkim  최초 생성
+ *  2024/09/24    이백행        컨트리뷰션 이클립스 문제(Problems) 해결
+ * 
+ */ +@Slf4j +@RequiredArgsConstructor +@RestController +public class MessageSourceApiController { + private final MessageSource messageSource; + + @GetMapping("/api/v1/messages/{code}/{lang}") + public String getMessage(@PathVariable String code, @PathVariable String lang) { + Locale locale = "en".equals(lang)? Locale.ENGLISH : Locale.KOREAN; + String message = messageSource.getMessage(code, null, locale); + log.info("code={}, lang={}, message={}", code, lang, message); + return message; + } +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/MessageSourceConfig.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/MessageSourceConfig.java new file mode 100644 index 0000000..b070d64 --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/MessageSourceConfig.java @@ -0,0 +1,64 @@ +package org.egovframe.cloud.apigateway.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * org.egovframe.cloud.apigateway.config.MessageSourceConfig + *

+ * Spring MessageSource 설정 + * Message Domain 이 있는 portal-service 에서 messages.properties 를 공유 가능한 외부 위치에 생성한다. + * 각 서비스에서 해당 파일을 통해 다국어를 지원하도록 한다. + * module-common.jar 를 포함하지 않는 서비스에서는 이 configuration을 추가해주어야 한다. + * + * @author 표준프레임워크센터 jaeyeolkim + * @version 1.0 + * @since 2021/08/09 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2021/08/09    jaeyeolkim  최초 생성
+ * 
+ */ +@Slf4j +@Configuration +public class MessageSourceConfig { + + @Value("${messages.directory}") + private String messagesDirectory; + + @Value("${spring.profiles.active:default}") + private String profile; + + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + final String MESSAGES = "/messages"; + if ("default".equals(profile)) { + Path fileStorageLocation = Paths.get(messagesDirectory).toAbsolutePath().normalize(); + String dbMessages = StringUtils.cleanPath("file://" + fileStorageLocation + MESSAGES); + log.info("DB MessageSource location = {}", dbMessages); + messageSource.setBasenames(dbMessages); + } else { + messageSource.setBasenames(messagesDirectory + MESSAGES); + } + messageSource.getBasenameSet().forEach(s -> log.info("messageSource getBasenameSet={}", s)); + + messageSource.setCacheSeconds(60); // 메세지 파일 변경 감지 간격 + messageSource.setUseCodeAsDefaultMessage(true); // 메세지가 없으면 코드를 메세지로 한다 + messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name()); + return messageSource; + } +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/OpenApiDocConfig.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/OpenApiDocConfig.java new file mode 100644 index 0000000..7b674e1 --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/OpenApiDocConfig.java @@ -0,0 +1,38 @@ +package org.egovframe.cloud.apigateway.config; + +import org.springdoc.core.GroupedOpenApi; +import org.springdoc.core.SwaggerUiConfigParameters; +import org.springframework.cloud.gateway.route.RouteDefinition; +import org.springframework.cloud.gateway.route.RouteDefinitionLocator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +@Configuration +public class OpenApiDocConfig { + + @Bean + @Lazy(false) + public List apis(SwaggerUiConfigParameters swaggerUiConfigParameters, RouteDefinitionLocator locator) { + List groups = new ArrayList<>(); + + List definitions = locator.getRouteDefinitions().log("OpenApiDocConfig").collectList().block(); + + Optional.ofNullable(definitions) + .map(Collection::stream) + .orElseGet(Stream::empty) + .filter(routeDefinition -> routeDefinition.getId().matches(".*-service")) + .forEach(routeDefinition -> { + String name = routeDefinition.getId(); + swaggerUiConfigParameters.addGroup(name); + GroupedOpenApi.builder().pathsToMatch("/" + name + "/**").group(name).build(); + }); + return groups; + } +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/RateLimiterConfig.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/RateLimiterConfig.java new file mode 100644 index 0000000..4820441 --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/RateLimiterConfig.java @@ -0,0 +1,79 @@ +package org.egovframe.cloud.apigateway.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import reactor.core.publisher.Mono; + +/** + * org.egovframe.cloud.apigateway.config.RateLimiterConfig + *

+ * 요청 제한(Rate Limiting) 설정 클래스 + * 동일한 IP 또는 경로에 대한 과도한 요청을 제한하기 위한 설정 + * + * @author changwng + * @version 1.0 + * @since 2025/03/19 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2025/03/19    changwng    최초 생성
+ * 
+ */ +@Slf4j +@Configuration +public class RateLimiterConfig { + + /** + * IP 주소 기반 요청 제한을 위한 KeyResolver 빈 + * 클라이언트 IP 주소를 키로 사용하여 요청 제한을 적용 + * + * @return KeyResolver IP 주소 기반 키 리졸버 + */ + // @Bean 주석 처리 - 다중 KeyResolver 빈 문제 해결 + public KeyResolver ipKeyResolver() { + return exchange -> { + String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(); + log.debug("Request IP: {}", ip); + return Mono.just(ip); + }; + } + + /** + * 요청 경로 기반 요청 제한을 위한 KeyResolver 빈 + * 요청 경로를 키로 사용하여 요청 제한을 적용 + * + * @return KeyResolver 요청 경로 기반 키 리졸버 + */ + // @Bean 주석 처리 - 다중 KeyResolver 빈 문제 해결 + public KeyResolver pathKeyResolver() { + return exchange -> { + String path = exchange.getRequest().getPath().value(); + log.debug("Request Path: {}", path); + return Mono.just(path); + }; + } + + /** + * IP와 경로를 조합한 요청 제한을 위한 KeyResolver 빈 + * IP 주소와 요청 경로를 조합하여 키로 사용함으로써 더 세밀한 요청 제한 적용 가능 + * + * @return KeyResolver IP와 경로 조합 기반 키 리졸버 + */ + @Bean + @Primary // 기본 KeyResolver 빈으로 지정 + public KeyResolver ipAndPathKeyResolver() { + return exchange -> { + String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(); + String path = exchange.getRequest().getPath().value(); + String key = ip + ":" + path; + log.debug("Request Key: {}", key); + return Mono.just(key); + }; + } +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/ReactiveAuthorization.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/ReactiveAuthorization.java new file mode 100644 index 0000000..bc5a2eb --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/ReactiveAuthorization.java @@ -0,0 +1,188 @@ +package org.egovframe.cloud.apigateway.config; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.RequestPath; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.access.AuthorizationServiceException; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.authorization.AuthorizationContext; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + + +/** + * org.egovframe.cloud.apigateway.config.ReactiveAuthorization + *

+ * Spring Security 에 의해 요청 url에 대한 사용자 인가 서비스를 수행하는 클래스 요청에 대한 사용자의 권한여부 체크하여 true/false 리턴한다 + * + * @author 표준프레임워크센터 jaeyeolkim + * @version 1.0 + * @since 2021/07/19 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2021/07/19    jaeyeolkim  최초 생성
+ * 
+ */ +@Slf4j +@Component +public class ReactiveAuthorization implements ReactiveAuthorizationManager { + + private final WebClient.Builder webClientBuilder; + private final MeterRegistry meterRegistry; + + private final CircuitBreaker authCircuitBreaker = CircuitBreaker.of("auth-service", + CircuitBreakerConfig.custom() + .failureRateThreshold(70) // 실패율 70% 이상이면 서킷 오픈 + .slowCallRateThreshold(50) // 50% 이상의 요청이 느린 경우 서킷 오픈 + .slowCallDurationThreshold(Duration.ofSeconds(3)) // 3초 이상 걸리면 느린 요청으로 간주 + .waitDurationInOpenState(Duration.ofSeconds(10)) // 서킷 오픈 후 10초 대기 + .permittedNumberOfCallsInHalfOpenState(10) // 반열림 상태에서 10개 요청 허용 + .slidingWindowSize(50) // 최근 50개 요청 기준으로 실패율 계산 + .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 카운트 기반 윈도우 + .build()); + private final Timer authServiceTimer; + + public ReactiveAuthorization(WebClient.Builder webClientBuilder, MeterRegistry meterRegistry) { + this.webClientBuilder = webClientBuilder; + this.meterRegistry = meterRegistry; + this.authServiceTimer = Timer.builder("auth.service.request.time") + .description("인증 서비스 요청 시간 측정") + .register(meterRegistry); + } + + @Value("${apigateway.host:http://localhost:8000}") + private String APIGATEWAY_HOST; + + @Value("${token.secret}") + private String TOKEN_SECRET; + + // org.egovframe.cloud.common.config.GlobalConstant 값도 같이 변경해주어야 한다. + public static final String AUTHORIZATION_URI = "/user-service" + "/api/v1/authorizations/check"; + public static final String REFRESH_TOKEN_URI = "/user-service" + "/api/v1/users/token/refresh"; + + /** + * 요청에 대한 사용자의 권한여부 체크하여 true/false 리턴한다 헤더에 토큰이 있으면 유효성을 체크한다. + * 리액티브 패턴을 사용하여 논블로킹 방식으로 구현 + * + * @param authentication + * @param context + * @return + * @see WebFluxSecurityConfig + */ + @Override + public Mono check(Mono authentication, + AuthorizationContext context) { + ServerHttpRequest request = context.getExchange().getRequest(); + RequestPath requestPath = request.getPath(); + HttpMethod httpMethod = request.getMethod(); + + String requestId = request.getId(); + String baseUrl = + APIGATEWAY_HOST + AUTHORIZATION_URI + "?httpMethod=" + httpMethod + "&requestPath=" + + requestPath; + log.info("baseUrl={}", baseUrl); + + List authorizations = + request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION) ? + request.getHeaders().get(HttpHeaders.AUTHORIZATION) : null; + + // 인증 헤더가 없거나 유효하지 않은 경우 + if (authorizations == null || authorizations.isEmpty() + || !StringUtils.hasLength(authorizations.get(0)) + || "undefined".equals(authorizations.get(0))) { + return Mono.just(new AuthorizationDecision(false)); + } + + String authorizationHeader = authorizations.get(0); + + // JWT 토큰 검증 + try { + String jwt = authorizationHeader.replace("Bearer", "").trim(); + String subject = Jwts.parser().setSigningKey(TOKEN_SECRET) + .parseClaimsJws(jwt) + .getBody() + .getSubject(); + + // refresh token 요청 시 토큰 검증만 하고 인가 처리 한다. + if (REFRESH_TOKEN_URI.equals(requestPath.toString())) { + return Mono.just(new AuthorizationDecision(true)); + } + + if (subject == null || subject.isEmpty()) { + log.error("토큰 인증 오류: 유효하지 않은 subject"); + return Mono.just(new AuthorizationDecision(false)); + } + } catch (IllegalArgumentException e) { + log.error("토큰 헤더 오류: {}", e.getMessage()); + return Mono.just(new AuthorizationDecision(false)); + } catch (ExpiredJwtException e) { + log.error("토큰 유효기간이 만료되었습니다: {}", e.getMessage()); + return Mono.just(new AuthorizationDecision(false)); + } catch (Exception e) { + log.error("토큰 인증 오류: {}", e.getMessage()); + return Mono.just(new AuthorizationDecision(false)); + } + + // 인가 서비스 호출 시간 측정을 위한 시작 시간 기록 + long start = System.nanoTime(); + // 인가 서비스 호출 (논블로킹 방식) + return webClientBuilder.build() + .get() + .uri(baseUrl) + .headers(httpHeaders -> httpHeaders.add(HttpHeaders.AUTHORIZATION, authorizationHeader)) + .retrieve() + .bodyToMono(Boolean.class) + .name("auth-service-call") + .metrics() + .transform(CircuitBreakerOperator.of(authCircuitBreaker)) //서킷 브레이커를 통해 반복적인 실패 시 빠르게 실패 처리하여 시스템 부하를 줄입니다.,구성된 서킷 브레이커는 50% 실패율에 10초 대기 시간을 가지고 있습니다. + .timeout(Duration.ofSeconds(5)) //5초 내에 응답이 없으면 타임아웃 처리됩니다. 2초내에 + .doOnError(TimeoutException.class, e -> + log.error("인가 서비스 요청 타임아웃: {}", e.getMessage())) + .doOnError(Exception.class, e -> + log.error("인가 서비스 요청 오류: {}", e.getMessage())) + .onErrorResume(TimeoutException.class, e -> { + log.error("인가 서비스 요청 타임아웃 - request ID: {}, 경로: {}", requestId, requestPath); + return Mono.just(false); // Fallback 처리 + }) + .onErrorResume(Exception.class, e -> { + log.error("인가 서비스 요청 실패 - request ID: {}, 오류: {}", requestId, e.getMessage()); + return Mono.just(false); + }) + /*.retryWhen(Retry.backoff(3, Duration.ofMillis(500)) //오류 발생 시 최대 3번까지 500ms의 지연을 두고 재시도합니다. + .filter(throwable -> !(throwable instanceof AuthorizationServiceException)))*/ + .defaultIfEmpty(false) // 응답이 비어있거나(empty) 오류가 발생한 경우 기본적으로 `false`를 반환합니다. + // .onErrorReturn(false)//이는 인가 실패로 처리되어 사용자의 접근이 거부됩니다. + .flatMap(granted -> { + long duration = System.nanoTime() - start; + authServiceTimer.record(duration, TimeUnit.NANOSECONDS); + log.info("인가 서비스 요청 시간: request ID: {}, {}ms, granted={}, requestPath={}", + requestId, String.format("%.2f", duration / 1_000_000.0), granted, requestPath.toString()); + return Mono.just(new AuthorizationDecision(granted)); + }); + } + +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/WebClientConfig.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/WebClientConfig.java new file mode 100644 index 0000000..3238411 --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/WebClientConfig.java @@ -0,0 +1,81 @@ +package org.egovframe.cloud.apigateway.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * org.egovframe.cloud.apigateway.config.WebClientConfig + *

+ * WebClient 설정 클래스 + * 타임아웃 및 연결 풀 설정을 통해 안정적인 외부 서비스 호출을 지원 + * + * @author IIPBS 개발팀 + * @version 1.0 + * @since 2025/03/19 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2025/03/19    IIPBS       최초 생성
+ * 
+ */ +@Configuration +public class WebClientConfig { + + /** + * 커넥션 풀 설정을 통해 효율적인 리소스 관리 + * + * @return ConnectionProvider + */ + @Bean + public ConnectionProvider connectionProvider() { + return ConnectionProvider.builder("http-pool") + .maxConnections(500) + .maxIdleTime(Duration.ofSeconds(60)) + .maxLifeTime(Duration.ofSeconds(300)) + .pendingAcquireTimeout(Duration.ofSeconds(30)) + .evictInBackground(Duration.ofSeconds(30)) + .build(); + } + + /** + * 타임아웃 설정이 적용된 HttpClient 빈 생성 + * + * @param connectionProvider 커넥션 풀 설정 + * @return HttpClient + */ + @Bean + public HttpClient httpClient(ConnectionProvider connectionProvider) { + return HttpClient.create(connectionProvider) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) + .responseTimeout(Duration.ofSeconds(5)) + .doOnConnected(conn -> + conn.addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS)) + .addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS))); + } + + /** + * 타임아웃 및 커넥션 풀 설정이 적용된 WebClient.Builder 빈 생성 + * + * @param httpClient 설정된 HttpClient + * @return WebClient.Builder + */ + @Bean + public WebClient.Builder webClientBuilder(HttpClient httpClient) { + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)); + } +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/WebFluxSecurityConfig.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/WebFluxSecurityConfig.java new file mode 100644 index 0000000..c0e8d53 --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/config/WebFluxSecurityConfig.java @@ -0,0 +1,67 @@ +package org.egovframe.cloud.apigateway.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint; +import org.springframework.security.web.server.authorization.AuthorizationContext; + +/** + * org.egovframe.cloud.apigateway.config.WebFluxSecurityConfig + *

+ * Spring Security Config 클래스 + * ReactiveAuthorizationManager 구현체 ReactiveAuthorization 클래스를 통해 인증/인가 처리를 구현한다. + * + * @author 표준프레임워크센터 jaeyeolkim + * @version 1.0 + * @since 2021/06/30 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2021/06/30    jaeyeolkim  최초 생성
+ * 
+ */ +@EnableWebFluxSecurity // Spring Security 설정들을 활성화시켜 준다 +public class WebFluxSecurityConfig { + + private final static String[] PERMITALL_ANTPATTERNS = { + ReactiveAuthorization.AUTHORIZATION_URI, "/", "/csrf", + "/user-service/login", "/?*-service/api/v1/messages/**", "/api/v1/messages/**", + "/?*-service/actuator/?*", "/actuator/?*", + "/v3/api-docs/**", "/?*-service/v3/api-docs", "**/configuration/*", "/swagger*/**", "/webjars/**" + }; + private final static String USER_JOIN_ANTPATTERNS = "/user-service/api/v1/users"; + + /** + * WebFlux 스프링 시큐리티 설정 + * + * @see ReactiveAuthorization + * @param http + * @param check check(Mono authentication, AuthorizationContext context) + * @return + * @throws Exception + */ + @Bean + public SecurityWebFilterChain configure(ServerHttpSecurity http, ReactiveAuthorizationManager check) throws Exception { + http + .csrf().disable() + .headers().frameOptions().disable() + .and() + .formLogin().disable() + .httpBasic().authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)) // login dialog disabled & 401 HttpStatus return + .and() + .authorizeExchange() + .pathMatchers(PERMITALL_ANTPATTERNS).permitAll() + .pathMatchers(HttpMethod.POST, USER_JOIN_ANTPATTERNS).permitAll() + .anyExchange().access(check); + return http.build(); + } + +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/exception/ReactiveExceptionHandlerConfig.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/exception/ReactiveExceptionHandlerConfig.java new file mode 100644 index 0000000..639735c --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/exception/ReactiveExceptionHandlerConfig.java @@ -0,0 +1,97 @@ +package org.egovframe.cloud.apigateway.exception; + +import lombok.extern.slf4j.Slf4j; +import org.egovframe.cloud.apigateway.exception.dto.ErrorCode; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.reactive.error.DefaultErrorAttributes; +import org.springframework.boot.web.reactive.error.ErrorAttributes; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.web.reactive.function.server.ServerRequest; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * org.egovframe.cloud.apigateway.exception.ReactiveExceptionHandlerConfig + *

+ * 에러 발생 시 에러 정보 중 필요한 내용만 반환한다 + * ErrorCode 에서 status, code, message 세 가지 속성을 의존한다 + * + * @author 표준프레임워크센터 jaeyeolkim + * @version 1.0 + * @since 2021/07/16 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2021/07/16    jaeyeolkim  최초 생성
+ *  2024/09/24    이백행        컨트리뷰션 이클립스 문제(Problems) 해결
+ * 
+ */ +@Slf4j +//@Configuration +public class ReactiveExceptionHandlerConfig { + + private final MessageSource messageSource; + + public ReactiveExceptionHandlerConfig(MessageSource messageSource) { + this.messageSource = messageSource; + } + + /** + * 에러 발생 시 에러 정보 중 필요한 내용만 반환한다 + * + * @return + */ +// @Bean + public ErrorAttributes errorAttributes() { + return new DefaultErrorAttributes() { + @Override + public Map getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) { + Map defaultMap = super.getErrorAttributes(request, options); + Map errorAttributes = new LinkedHashMap<>(); + + int status = (int) defaultMap.get("status"); + ErrorCode errorCode = getErrorCode(status); + String message = messageSource.getMessage(errorCode.getMessage(), null, LocaleContextHolder.getLocale()); + errorAttributes.put("timestamp", LocalDateTime.now()); + errorAttributes.put("message", message); + errorAttributes.put("status", status); + errorAttributes.put("code", errorCode.getCode()); + // API Gateway 에서 FieldError는 처리하지 않는다. + + log.error("getErrorAttributes()={}", defaultMap); + return errorAttributes; + } + }; + } + + /** + * 상태코드로부터 ErrorCode 를 매핑하여 리턴한다. + * + * @param status + * @return + */ + private ErrorCode getErrorCode(int status) { + switch (status) { + case 400: + return ErrorCode.ENTITY_NOT_FOUND; + case 401: + return ErrorCode.UNAUTHORIZED; + case 403: + return ErrorCode.ACCESS_DENIED; + case 404: + return ErrorCode.NOT_FOUND; + case 405: + return ErrorCode.METHOD_NOT_ALLOWED; + case 422: + return ErrorCode.UNPROCESSABLE_ENTITY; + default: + return ErrorCode.INTERNAL_SERVER_ERROR; + } + } +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/exception/dto/ErrorCode.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/exception/dto/ErrorCode.java new file mode 100644 index 0000000..4da3263 --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/exception/dto/ErrorCode.java @@ -0,0 +1,59 @@ +package org.egovframe.cloud.apigateway.exception.dto; + +/** + * org.egovframe.cloud.common.exception.dto.ErrorCode + *

+ * REST API 요청에 대한 오류 반환값을 정의 + * ErrorResponse 클래스에서 status, code, message 세 가지 속성을 의존한다 + * message 는 MessageSource 의 키 값을 정의하여 다국어 처리를 지원한다 + * + * @author 표준프레임워크센터 jaeyeolkim + * @version 1.0 + * @since 2021/07/16 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2021/07/16    jaeyeolkim  최초 생성
+ * 
+ */ +public enum ErrorCode { + + INVALID_INPUT_VALUE(400, "E001", "err.invalid.input.value"), // Bad Request + INVALID_TYPE_VALUE(400, "E002", "err.invalid.type.value"), // Bad Request + ENTITY_NOT_FOUND(400, "E003", "err.entity.not.found"), // Bad Request + UNAUTHORIZED(401, "E004", "err.unauthorized"), // The request requires an user authentication + ACCESS_DENIED(403, "E005", "err.access.denied"), // Forbidden, Access is Denied + NOT_FOUND(404, "E007", "err.not.found"), // Not found + METHOD_NOT_ALLOWED(405, "E008", "err.method.not.allowed"), // 요청 방법이 서버에 의해 알려졌으나, 사용 불가능한 상태 + UNPROCESSABLE_ENTITY(422, "E009", "err.unprocessable.entity"), // Unprocessable Entity + INTERNAL_SERVER_ERROR(500, "E999", "err.internal.server"), // Server Error + SERVICE_UNAVAILABLE(503, "E010", "err.service.unavailable") // Service Unavailable + ; + + + private final int status; + private final String code; + private final String message; + + ErrorCode(final int status, final String code, final String message) { + this.status = status; + this.code = code; + this.message = message; + } + + public int getStatus() { + return status; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } + +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/GlobalFilter.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/GlobalFilter.java new file mode 100644 index 0000000..7c91e45 --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/GlobalFilter.java @@ -0,0 +1,53 @@ +/* + +기본필터를 삭제 처리함 (Global Filter) +package org.egovframe.cloud.apigateway.filter; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +public class GlobalFilter extends AbstractGatewayFilterFactory { + + public GlobalFilter() { + super(Config.class); + } + + @Override + public GatewayFilter apply(Config config) { + // Pre filter + return ((exchange, chain) -> { + // Netty 비동기 방식 서버 사용시에는 ServerHttpRequest 를 사용해야 한다. + ServerHttpRequest request = exchange.getRequest(); + ServerHttpResponse response = exchange.getResponse(); + + if (config.isPreLogger()) { + log.info("[GlobalFilter Start] request ID: {}, method: {}, path: {}", request.getId(), request.getMethod(), request.getPath()); + } + + // Post Filter + // 비동기 방식의 단일값 전달시 Mono 사용(Webflux) + return chain.filter(exchange).then(Mono.fromRunnable(() -> { + if (config.isPostLogger()) { + log.info("[GlobalFilter End ] request ID: {}, method: {}, path: {}, statusCode: {}", request.getId(), request.getMethod(), request.getPath(), response.getStatusCode()); + } + })); + }); + } + + @Data + public static class Config { + // put the configure + private String baseMessage; + private boolean preLogger; + private boolean postLogger; + } +} +*/ diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/RateLimitingFilter.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/RateLimitingFilter.java new file mode 100644 index 0000000..6ed46ac --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/RateLimitingFilter.java @@ -0,0 +1,235 @@ +package org.egovframe.cloud.apigateway.filter; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * org.egovframe.cloud.apigateway.filter.RateLimitingFilter + *

+ * 요청 제한(Rate Limiting) 필터 + * 동일한 경로에 대한 과도한 요청을 제한하기 위한 필터 + * + * @author changwng + * @version 1.0 + * @since 2025/03/19 + * + *

+ * << 개정이력(Modification Information) >>
+ *
+ *     수정일        수정자           수정내용
+ *  ----------    --------    ---------------------------
+ *  2025/03/19    changwng    최초 생성
+ * 
+ */ +@Slf4j +@Component +public class RateLimitingFilter extends AbstractGatewayFilterFactory { + + // 요청 경로별 카운터 맵 (메모리 기반 구현) + private final Map pathCounters = new ConcurrentHashMap<>(); + + // 요청 제한 임계값 (기본값: 10) + private static final int DEFAULT_REQUEST_LIMIT = 10; + + // 요청 제한 시간 윈도우 (기본값: 1초 = 1000ms) + private static final long DEFAULT_TIME_WINDOW = 1000L; + + // 차단 유지 시간 (기본값: 3분 = 180초) + private static final long DEFAULT_BLOCK_DURATION = 180L; + + // 차단된 클라이언트 정보를 저장하는 맵 + private final Map blockedClients = new ConcurrentHashMap<>(); + + public RateLimitingFilter() { + super(Config.class); + } + + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> { + ServerHttpRequest request = exchange.getRequest(); + ServerHttpResponse response = exchange.getResponse(); + + // 요청 경로 추출 + String path = request.getPath().value(); + + // 클라이언트 IP 주소 추출 + String ip = request.getRemoteAddress().getAddress().getHostAddress(); + + // IP와 경로를 조합한 키 생성 (같은 IP에서 동일한 엔드포인트 요청 제한) + String key = ip + ":" + path; + + // 요청 제한 임계값 설정 + int requestLimit = config.getRequestLimit() > 0 ? config.getRequestLimit() : DEFAULT_REQUEST_LIMIT; + + // 요청 제한 시간 윈도우 설정 + long timeWindow = config.getTimeWindow() > 0 ? config.getTimeWindow() : DEFAULT_TIME_WINDOW; + + // 차단 유지 시간 설정 (초 단위) + long blockDuration = config.getBlockDuration() > 0 ? config.getBlockDuration() : DEFAULT_BLOCK_DURATION; + + // IP+경로별 카운터 가져오기 (없으면 생성) + PathCounter counter = pathCounters.computeIfAbsent(key, k -> new PathCounter()); + + // 이미 차단된 클라이언트인지 확인 + if (counter.isBlocked()) { + // 차단 상태인 경우 남은 시간 계산 + Duration remainingTime = Duration.between(LocalDateTime.now(), counter.getBlockedUntil()); + long remainingSeconds = Math.max(1, remainingTime.getSeconds()); // 최소 1초 + + log.warn("Client is blocked - IP: {}, Path: {}, Remaining time: {} seconds", ip, path, remainingSeconds); + + // 에러 메시지 생성 + String errorMessage = String.format( + "{\"error\":\"요청 제한 차단\",\"message\":\"짧은 시간 내에 너무 많은 요청이 발생하여 일시적으로 차단되었습니다. %d초 후에 다시 시도해주세요.\",\"path\":\"%s\"}", + remainingSeconds, path); + + // 응답 설정 + response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS); + DataBuffer buffer = response.bufferFactory().wrap(errorMessage.getBytes(StandardCharsets.UTF_8)); + + // 에러 응답 반환 + return response.writeWith(Mono.just(buffer)); + } + + // 현재 시간 + long currentTime = System.currentTimeMillis(); + + // 시간 윈도우가 지났으면 카운터 초기화 + if (currentTime - counter.getLastResetTime() > timeWindow) { + counter.reset(currentTime); + } + + // 요청 카운트 증가 + int currentCount = counter.incrementAndGet(); + + // 로그 기록 + log.debug("IP: {}, Path: {}, Count: {}/{}, Window: {}ms", ip, path, currentCount, requestLimit, timeWindow); + + // 요청 제한 초과 시 차단 처리 및 에러 응답 반환 + if (currentCount > requestLimit) { + // 차단 상태로 설정 (3분 = 180초) + counter.block(blockDuration); + + log.warn("Rate limit exceeded, client blocked - IP: {} and path: {}, blocked until: {}", + ip, path, counter.getBlockedUntil()); + + // 에러 메시지 생성 + String errorMessage = String.format( + "{\"error\":\"요청 제한 초과\",\"message\":\"짧은 시간 내에 너무 많은 요청이 발생하여 %d분 동안 차단되었습니다. %d분 후에 다시 시도해주세요.\",\"path\":\"%s\"}", + blockDuration / 60, blockDuration / 60, path); + + // 응답 설정 + response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS); + DataBuffer buffer = response.bufferFactory().wrap(errorMessage.getBytes(StandardCharsets.UTF_8)); + + // 에러 응답 반환 + return response.writeWith(Mono.just(buffer)); + } + + // 정상 요청 처리 + return chain.filter(exchange); + }; + } + + /** + * 경로별 요청 카운터 클래스 + */ + private static class PathCounter { + private int count; + private long lastResetTime; + private boolean blocked; + private LocalDateTime blockedUntil; + + public PathCounter() { + this.count = 0; + this.lastResetTime = System.currentTimeMillis(); + this.blocked = false; + this.blockedUntil = null; + } + + public synchronized int incrementAndGet() { + return ++count; + } + + public synchronized void reset(long resetTime) { + this.count = 0; + this.lastResetTime = resetTime; + } + + public synchronized void block(long blockDurationSeconds) { + this.blocked = true; + this.blockedUntil = LocalDateTime.now().plusSeconds(blockDurationSeconds); + } + + public synchronized boolean isBlocked() { + if (!blocked) { + return false; + } + + // 차단 시간이 지났는지 확인 + if (LocalDateTime.now().isAfter(blockedUntil)) { + // 차단 시간이 지나면 차단 해제 + this.blocked = false; + this.blockedUntil = null; + return false; + } + + return true; + } + + public LocalDateTime getBlockedUntil() { + return blockedUntil; + } + + public long getLastResetTime() { + return lastResetTime; + } + } + + /** + * 필터 설정 클래스 + */ + public static class Config { + private int requestLimit; + private long timeWindow; + private long blockDuration; + + public int getRequestLimit() { + return requestLimit; + } + + public void setRequestLimit(int requestLimit) { + this.requestLimit = requestLimit; + } + + public long getTimeWindow() { + return timeWindow; + } + + public void setTimeWindow(long timeWindow) { + this.timeWindow = timeWindow; + } + + public long getBlockDuration() { + return blockDuration; + } + + public void setBlockDuration(long blockDuration) { + this.blockDuration = blockDuration; + } + } +} diff --git a/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/RequestTimingFilter.java b/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/RequestTimingFilter.java new file mode 100644 index 0000000..e106157 --- /dev/null +++ b/apigateway/src/main/java/org/egovframe/cloud/apigateway/filter/RequestTimingFilter.java @@ -0,0 +1,89 @@ +package org.egovframe.cloud.apigateway.filter; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +public class RequestTimingFilter implements GlobalFilter, Ordered { + + private final MeterRegistry meterRegistry; + + public RequestTimingFilter(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + // 요청 정보 추출 + ServerHttpRequest request = exchange.getRequest(); + String path = request.getPath().value(); + String method = request.getMethod().name(); + + // 시작 시간 기록 + long startTime = System.nanoTime(); + + // 요청 진행 및 완료 후 처리 + return chain.filter(exchange) + .doFinally(signalType -> { + // 종료 시간 계산 및 경과 시간 측정 + long endTime = System.nanoTime(); + long duration = endTime - startTime; + double durationInMs = duration / 1_000_000.0; + + + // 요청 처리 시간 기록 (밀리초) + String serviceName = extractServiceName(path); + + // 메트릭 기록 - 경로별 타이머 + Timer pathTimer = Timer.builder("gateway.request.time") + .tag("path", path) + .tag("method", method) + .tag("service", serviceName) + .description("API Gateway 요청 처리 시간") + .register(meterRegistry); + pathTimer.record(duration, TimeUnit.NANOSECONDS); + String pathQuery = path+(request.getURI().getQuery() == null ? "" : "?" + request.getURI().getQuery()); + // 로그 기록 + log.info("Request completed -request ID: {}, Method: {}, Path: {}, Service: {}, Duration: {}ms", + request.getId(), method, pathQuery , serviceName, String.format("%.2f",durationInMs)); + }); + } + + @Override + public int getOrder() { + // 다른 필터들보다 먼저 실행되어야 함 + return Ordered.HIGHEST_PRECEDENCE; + } + + /** + * 요청 경로에서 서비스 이름 추출 + * 예: /user-service/api/v1/users -> user-service + */ + private String extractServiceName(String path) { + if (path == null || path.isEmpty() || !path.startsWith("/")) { + return "unknown"; + } + + // 첫 번째 '/' 제거 + String trimmedPath = path.substring(1); + + // 다음 '/'까지가 서비스 이름 + int nextSlash = trimmedPath.indexOf('/'); + if (nextSlash > 0) { + return trimmedPath.substring(0, nextSlash); + } else { + return trimmedPath.isEmpty() ? "root" : trimmedPath; + } + } +} \ No newline at end of file diff --git a/apigateway/src/main/resources/application.yml b/apigateway/src/main/resources/application.yml new file mode 100644 index 0000000..86ea601 --- /dev/null +++ b/apigateway/src/main/resources/application.yml @@ -0,0 +1,91 @@ +server: + port: 8000 + +spring: + application: + name: apigateway + cloud: + loadbalancer: + cache: + caffeine: + spec: expireAfterWrite=5s + gateway: + httpclient: + connect-timeout: 5000 # 3초 내에 연결되지 않으면 실패 + response-timeout: 10s # 5초 내에 응답이 없으면 타임아웃 + routes: + - id: user-service + uri: lb://USER-SERVICE + predicates: + - Path=/user-service/** + filters: + - RemoveRequestHeader=Cookie + - RewritePath=/user-service/(?.*), /$\{segment} + - id: board-service + uri: lb://BOARD-SERVICE + predicates: + - Path=/board-service/** + filters: + - RewritePath=/board-service/(?.*), /$\{segment} + - id: cms-service + uri: lb://CMS-SERVICE + predicates: + - Path=/cms-service/** + filters: + - RewritePath=/cms-service/(?.*), /$\{segment} + - id: attach-service + uri: lb://ATTACH-SERVICE + predicates: + - Path=/attach-service/** + filters: + - RewritePath=/attach-service/(?.*), /$\{segment} + - id: openapi + uri: http://localhost:${server.port} + predicates: + - Path=/v3/api-docs/** + filters: + - RewritePath=/v3/api-docs/(?.*), /$\{segment}/v3/api-docs + default-filters: + # - name: GlobalFilter + # args: + # preLogger: true + # postLogger: true + # - name: RateLimitingFilter #같은 Ip에서 대량의 데이터 요청시에 차단 처리 + # args: + # requestLimit: 10 + # timeWindow: 1000 + # blockDuration: 180 + discovery: + locator: + enabled: true + globalcors: + corsConfigurations: + '[/**]': + allowedOriginPatterns: + - "*" + allowedMethods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + - HEAD + - PATCH + allowedHeaders: + - "*" + allowCredentials: true +# config server actuator +management: + endpoints: + web: + exposure: + include: refresh, health, beans, info + +info: + app: + name: Spring Cloud Gateway + description: API 라우팅 및 보안, 모니터링/메트릭 등의 기능을 간단하고 효과적인 방법으로 제공 + swagger-ui: http://localhost:8000/webjars/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config + +messages: + directory: ${user.dir}/msa-attach-volume/messages diff --git a/apigateway/src/main/resources/bootstrap.yml b/apigateway/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..c23f12f --- /dev/null +++ b/apigateway/src/main/resources/bootstrap.yml @@ -0,0 +1,5 @@ +spring: + cloud: + config: + uri: http://localhost:8888 + name: application \ No newline at end of file diff --git a/apigateway/src/main/resources/logback-spring.xml b/apigateway/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..0400865 --- /dev/null +++ b/apigateway/src/main/resources/logback-spring.xml @@ -0,0 +1,49 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(%-4relative) - [%thread{10}, %X{traceId:-}, %X{spanId:-}] %cyan(%logger{20}): %msg%n + + + + + + + + + + + + + + + + + ${destination} + + + + + + + + + {"appName": "${app_name}"} + + + + + + + + + + + + + + + + diff --git a/apigateway/src/main/resources/prod/application_prod.yml b/apigateway/src/main/resources/prod/application_prod.yml new file mode 100644 index 0000000..33a29be --- /dev/null +++ b/apigateway/src/main/resources/prod/application_prod.yml @@ -0,0 +1,76 @@ +server: + port: 8000 + +spring: + application: + name: apigateway + cloud: + loadbalancer: + cache: + caffeine: + spec: expireAfterWrite=5s + gateway: + httpclient: + connect-timeout: 5000 # 3초 내에 연결되지 않으면 실패 + response-timeout: 10s # 5초 내에 응답이 없으면 타임아웃 + routes: + - id: user-service + uri: lb://USER-SERVICE + predicates: + - Path=/user-service/** + filters: + - RemoveRequestHeader=Cookie + - RewritePath=/user-service/(?.*), /$\{segment} + - id: board-service + uri: lb://BOARD-SERVICE + predicates: + - Path=/board-service/** + filters: + - RewritePath=/board-service/(?.*), /$\{segment} + - id: cms-service + uri: lb://CMS-SERVICE + predicates: + - Path=/cms-service/** + filters: + - RewritePath=/cms-service/(?.*), /$\{segment} + - id: attach-service + uri: lb://ATTACH-SERVICE + predicates: + - Path=/attach-service/** + filters: + - RewritePath=/attach-service/(?.*), /$\{segment} + - id: openapi + uri: http://localhost:${server.port} + predicates: + - Path=/v3/api-docs/** + filters: + - RewritePath=/v3/api-docs/(?.*), /$\{segment}/v3/api-docs + default-filters: + # - name: GlobalFilter + # args: + # preLogger: true + # postLogger: true + # - name: RateLimitingFilter #같은 Ip에서 대량의 데이터 요청시에 차단 처리 + # args: + # requestLimit: 10 + # timeWindow: 1000 + # blockDuration: 180 + discovery: + locator: + enabled: true + +# config server actuator +management: + endpoints: + web: + exposure: + include: refresh, health, beans, info + +info: + app: + name: Spring Cloud Gateway + description: API 라우팅 및 보안, 모니터링/메트릭 등의 기능을 간단하고 효과적인 방법으로 제공 + swagger-ui: http://localhost:8000/webjars/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config + +messages: + directory: ${user.dir}/msa-attach-volume/messages diff --git a/apigateway/src/test/java/org/egovframe/cloud/apigateway/ApigatewayApplicationTests.java b/apigateway/src/test/java/org/egovframe/cloud/apigateway/ApigatewayApplicationTests.java new file mode 100644 index 0000000..0d4dc91 --- /dev/null +++ b/apigateway/src/test/java/org/egovframe/cloud/apigateway/ApigatewayApplicationTests.java @@ -0,0 +1,15 @@ +package org.egovframe.cloud.apigateway; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles(profiles = "test") +@SpringBootTest +class ApigatewayApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/apigateway/src/test/java/org/egovframe/cloud/apigateway/config/MessageSourceConfigTest.java b/apigateway/src/test/java/org/egovframe/cloud/apigateway/config/MessageSourceConfigTest.java new file mode 100644 index 0000000..9ce2c52 --- /dev/null +++ b/apigateway/src/test/java/org/egovframe/cloud/apigateway/config/MessageSourceConfigTest.java @@ -0,0 +1,27 @@ +package org.egovframe.cloud.apigateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@ActiveProfiles(profiles = "test") +@SpringBootTest(webEnvironment = RANDOM_PORT) +class MessageSourceConfigTest { + + @Autowired + TestRestTemplate restTemplate; + + @Test + public void 메세지를_외부위치에서_읽어온다() throws Exception { + // when + String message = restTemplate.getForObject("/api/v1/messages/common.login/ko", String.class); + + // then + assertThat(message).isEqualTo("로그인"); + } +} \ No newline at end of file diff --git a/apigateway/src/test/java/org/egovframe/cloud/apigateway/config/ReactiveAuthorizationTest.java b/apigateway/src/test/java/org/egovframe/cloud/apigateway/config/ReactiveAuthorizationTest.java new file mode 100644 index 0000000..1f6a27d --- /dev/null +++ b/apigateway/src/test/java/org/egovframe/cloud/apigateway/config/ReactiveAuthorizationTest.java @@ -0,0 +1,34 @@ +package org.egovframe.cloud.apigateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.reactive.server.WebTestClient; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@EnableConfigurationProperties +@TestPropertySource(properties = {"spring.config.location=classpath:application-test.yml"}) +@ActiveProfiles(profiles = "test") +class ReactiveAuthorizationTest { + + @Autowired + private WebTestClient webTestClient; + + @Test + public void API요청시_토큰인증_만료된다() throws Exception { + // given + String notValidToken = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI2NWEwMGY2NS04NDYwLTQ5YWYtOThlYy0wNDI5NzdlNTZmNGIiLCJhdXRob3JpdGllcyI6IlJPTEVfVVNFUiIsImV4cCI6MTYyNjc4MjQ0N30.qiScvtr1m88SHPLpHqcJiklXFyIQ7WBJdiFcdcb2B8YSWC59QcdRRgMtXDGSZnjBgF194W-GRBpHUta6VCkrfQ"; + + // when, then + webTestClient + .get() + .uri("/user-service/api/v1/users") + .header(HttpHeaders.AUTHORIZATION, notValidToken) + .exchange().expectStatus().isUnauthorized() + ; + } +} \ No newline at end of file diff --git a/apigateway/src/test/resources/application-test.yml b/apigateway/src/test/resources/application-test.yml new file mode 100644 index 0000000..64f0b29 --- /dev/null +++ b/apigateway/src/test/resources/application-test.yml @@ -0,0 +1,25 @@ +server: + port: 8000 + +spring: + application: + name: apigateway + +file: + directory: ${user.home}/msa-attach-volume +messages: + directory: ${file.directory}/messages + +# jwt token +token: + secret: egovframe_user_token + +# ftp server +ftp: + enabled: false # ftp 사용 여부, FTP 서버에 최상위 디렉토리 자동 생성 및 구현체를 결정하게 된다. + +# eureka 가 포함되면 eureka server 도 등록되므로 해제한다. +eureka: + client: + register-with-eureka: false + fetch-registry: false