Compare commits
4
Commits
apigateway
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc78c9974f | ||
|
|
87ad707415 | ||
|
|
9ff91e3f9a | ||
|
|
121be6cdc6 |
@@ -1,3 +1,128 @@
|
||||
# IIPBS
|
||||
|
||||
백엔드 저장소
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
'클라우드 네이티브 기반 행정·공공 서비스 확산 지원(2021)' 사업의 일환으로 제작된 MSA(Microservices Architecture) 템플릿을 활용하여 유역하수도정보스시템 백엔드 시스템을 개발한 소스코드이다.
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
├─apigateway
|
||||
├─attach-service
|
||||
├─board-service
|
||||
├─config-service
|
||||
├─cms-service
|
||||
├─discovery
|
||||
├─module-common
|
||||
├─user-service
|
||||
├─config
|
||||
├─docker-compose
|
||||
│ ├─app
|
||||
│ │ ├─mesh
|
||||
│ │ └─service
|
||||
│ ├─elk
|
||||
│ │ ├─kibana
|
||||
│ │ │ └─config
|
||||
│ │ └─logstash
|
||||
│ │ ├─config
|
||||
│ │ └─pipeline
|
||||
│ └─opensearch
|
||||
│ └─logstash
|
||||
│ ├─config
|
||||
│ └─pipeline
|
||||
└─k8s
|
||||
├─applications
|
||||
│ └─backend
|
||||
│ ├─apigateway
|
||||
│ │ └─ingress
|
||||
│ ├─board-service
|
||||
│ ├─config-service
|
||||
│ ├─discovery
|
||||
│ │ └─ingress
|
||||
│ ├─cms-service
|
||||
│ ├─attach-service
|
||||
│ └─user-service
|
||||
└─environments
|
||||
├─configmaps
|
||||
├─jenkins
|
||||
├─logging
|
||||
│ └─elk
|
||||
│ ├─elasticsearch
|
||||
│ ├─kibana
|
||||
│ │ └─ingress
|
||||
│ └─logstash
|
||||
├─nfs
|
||||
├─rabbitmq
|
||||
│ └─ingress
|
||||
├─storage
|
||||
├─vagrant
|
||||
└─zipkin
|
||||
└─ingress
|
||||
```
|
||||
|
||||
### 디렉토리 설명
|
||||
|
||||
- `/: Spring Boot를 기반으로 백앤드에 올려지는 서비스들
|
||||
- `/apigateway`: Microservice에 대한 API 관리 및 모니터링 서비스
|
||||
- `/board-service`: 게시판 서비스 (게시판, 게시물, 첨부파일 관리)
|
||||
- `/config-service`: 별도의 통합된 설정 관리 서비스 제공을 통해 환경 독립적 서비스 제공
|
||||
- `/discovery`: 마이크로서비스들을 등록하여 관리하고 요청 시 해당 서비스를 찾아 호출
|
||||
- `/user-service`: 사용자 서비스 ( 인가, 로그인, 회원가입, 비밀번호 찾기, 관리자)
|
||||
- `/cms-service`: CMS 공통 서비스 (메뉴, 코드, 컨텐츠, 권한, 배너, 서비스, 사이트 관리)
|
||||
- `/attach-service`: TUS 첨부파일 공통 서비스 (첨부파일 관리)
|
||||
- `/config`: backend 의 config 서버에서 사용하는 설정 yaml 파일 모음 폴더
|
||||
- `/docker-compose`: Docker에 여러 컨테이너의 실행을 관리하는 yaml 구성 파일들
|
||||
- `/k8s`: Kubernetes 환경 배포를 위한 Resource 템플릿 yaml 파일
|
||||
- `/k8s/applications`: Backend와 Frontend 애플리케이션의 k8s Resource 템플릿 yaml 파일
|
||||
- `/k8s/environments/configmaps`: Pods에서 사용하는 환경변수로서 Key-Value 쌍의 형태로 저장된 객체 정의
|
||||
- `/k8s/environments/jenkins`: Jenkins 관련 객체 정의
|
||||
- `/k8s/environments/logging`: Centralized Logging을 위한 ELK 관련 객체 정의
|
||||
- `/k8s/environments/nfs`: NFS 노드 볼륨 구성
|
||||
- `/k8s/environments/rabbitmq`: Message Broker 관련 객체 정의
|
||||
- `/k8s/environments/storage`: PersistentVolumeClaim 정의
|
||||
- `/k8s/environments/vagrant`: 가상화 소프트웨어 개발환경의 생성을 쉽게 구성하도록 하는 Vagrant 관련 정의
|
||||
- `/k8s/environments/zipkin`: 분산 트랜잭션 추적을 위한 Zipkin 관련 정의
|
||||
|
||||
## 백앤드 구동 방법
|
||||
|
||||
- 개발환경 Eclipse IDE 를 실행한다.
|
||||
- Eclipse IDE 메뉴에서 File>Import… 를 클릭한다.
|
||||
- Import 창이 열리면 Gradle>Existing Gradle Project 를 선택하고 Next 버튼을 클릭한다.
|
||||
- Import Gradle Project 창이 열리면 Next 버튼을 클릭한다.
|
||||
- Project root directory 에서 ${home}/config를 선택하고 Finish 버튼을 클릭한다.
|
||||
- 위의 과정을 반복하여 아래의 프로젝트를 import 한다. (소규모는 1-6, 대규모는 1-9)
|
||||
|
||||
1. config-service
|
||||
2. discovery
|
||||
3. apigateway
|
||||
4. attach-service
|
||||
5. cms-service
|
||||
6. user-service
|
||||
7. board-service
|
||||
|
||||
- 모든 프로젝트를 import 하고 Project Explorer 를 확인하면 board-service, portal-service, user-service 프로젝트에 오류 표시가 출력된다. querydsl 로 generate 되는 클래스들을 build path 에 추가해야 한다.
|
||||
- Window>Show View>Other 을 클릭해서 열린 창에서 Gradle>Gradle Tasks 를 선택하고 Open 버튼을 클릭하면 Gradle Tasks 탭이 열린다.
|
||||
- Gradle Tasks 오른쪽 윗부분의 View Menu 버튼을 클릭해서 Show All Tasks 를 체크한다.
|
||||
- Gradle Tasks 에서 portal-service>other>compileQuerydsl 을 더블클릭 또는 우클릭 후 Run Gradle Tasks 를 클릭하면 build 가 시작된다.
|
||||
- Project Explorer 에서 board-service, portal-service, user-service 를 선택하고 F5 또는 우클릭 후 Refresh 를 클릭해서 프로젝트를 새로고침한다.
|
||||
- Project Explorer 에서 board-service, portal-service, user-service 를 우클릭하고 Properties 를 클릭한다.
|
||||
- Properties 창이 열리면 왼쪽 메뉴에서 Java Build Path를 선택하고 오른쪽 Source 탭에서 Add Folder… 버튼을 클릭한다.
|
||||
- Source Folder Selection 창이 열리면 build>generated>querydsl 을 체크하고 OK 버튼을 클릭한다.
|
||||
- Properties 창에서 Apply and Close 버튼을 클릭하면 창이 닫히면서 프로젝트를 다시 빌드하고 오류 표시는 사라진다.
|
||||
|
||||
(ELK 설정, Config 설정, OAuth 2.0 설정, API 호출 및 JUnit 테스트 관련해서는 [02.MSA템플릿 백엔드구성 및 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084631/02.MSA._.pdf) 파일을 참조한다.)
|
||||
|
||||
|
||||
## 교육 교재
|
||||
|
||||
- [01.MSA템플릿 개요 및 개발환경 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084628/01.MSA._._.pdf)
|
||||
- [01.MSA템플릿 개요 및 개발환경 이론.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084629/01.MSA._._.pdf)
|
||||
- [02.MSA템플릿 백엔드구성 및 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084631/02.MSA._.pdf)
|
||||
- [03.MSA템플릿 프론트엔드구성 및 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084632/03.MSA._.pdf)
|
||||
- [04.MSA템플릿 이벤트스트림구성 및 실습.pdf](https://github.com/eGovFramework/egovframe-msa-edu/files/11084633/04.MSA._.pdf)
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Vendored
+48
@@ -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}.'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
Vendored
+185
@@ -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" "$@"
|
||||
Vendored
+89
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'apigateway'
|
||||
@@ -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
|
||||
* <p>
|
||||
* 게이트웨이 어플리케이션 클래스
|
||||
* Eureka Client 로 설정했기 때문에 Eureka Server 가 먼저 기동되어야 한다.
|
||||
*
|
||||
* @author 표준프레임워크센터 jaeyeolkim
|
||||
* @version 1.0
|
||||
* @since 2021/06/30
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2021/06/30 jaeyeolkim 최초 생성
|
||||
* </pre>
|
||||
*/
|
||||
@EnableDiscoveryClient
|
||||
@SpringBootApplication
|
||||
public class ApigatewayApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ApigatewayApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -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
|
||||
* <p>
|
||||
* MessageSource 정상 확인을 위한 컨트롤러
|
||||
*
|
||||
* @author 표준프레임워크센터 jaeyeolkim
|
||||
* @version 1.0
|
||||
* @since 2021/08/10
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2021/08/10 jaeyeolkim 최초 생성
|
||||
* 2024/09/24 이백행 컨트리뷰션 이클립스 문제(Problems) 해결
|
||||
* </pre>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
+64
@@ -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
|
||||
* <p>
|
||||
* Spring MessageSource 설정
|
||||
* Message Domain 이 있는 portal-service 에서 messages.properties 를 공유 가능한 외부 위치에 생성한다.
|
||||
* 각 서비스에서 해당 파일을 통해 다국어를 지원하도록 한다.
|
||||
* module-common.jar 를 포함하지 않는 서비스에서는 이 configuration을 추가해주어야 한다.
|
||||
*
|
||||
* @author 표준프레임워크센터 jaeyeolkim
|
||||
* @version 1.0
|
||||
* @since 2021/08/09
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2021/08/09 jaeyeolkim 최초 생성
|
||||
* </pre>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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<GroupedOpenApi> apis(SwaggerUiConfigParameters swaggerUiConfigParameters, RouteDefinitionLocator locator) {
|
||||
List<GroupedOpenApi> groups = new ArrayList<>();
|
||||
|
||||
List<RouteDefinition> 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* <p>
|
||||
* 요청 제한(Rate Limiting) 설정 클래스
|
||||
* 동일한 IP 또는 경로에 대한 과도한 요청을 제한하기 위한 설정
|
||||
*
|
||||
* @author changwng
|
||||
* @version 1.0
|
||||
* @since 2025/03/19
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2025/03/19 changwng 최초 생성
|
||||
* </pre>
|
||||
*/
|
||||
@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);
|
||||
};
|
||||
}
|
||||
}
|
||||
+188
@@ -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
|
||||
* <p>
|
||||
* Spring Security 에 의해 요청 url에 대한 사용자 인가 서비스를 수행하는 클래스 요청에 대한 사용자의 권한여부 체크하여 true/false 리턴한다
|
||||
*
|
||||
* @author 표준프레임워크센터 jaeyeolkim
|
||||
* @version 1.0
|
||||
* @since 2021/07/19
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2021/07/19 jaeyeolkim 최초 생성
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ReactiveAuthorization implements ReactiveAuthorizationManager<AuthorizationContext> {
|
||||
|
||||
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<AuthorizationDecision> check(Mono<Authentication> 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<String> 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));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
* <p>
|
||||
* WebClient 설정 클래스
|
||||
* 타임아웃 및 연결 풀 설정을 통해 안정적인 외부 서비스 호출을 지원
|
||||
*
|
||||
* @author IIPBS 개발팀
|
||||
* @version 1.0
|
||||
* @since 2025/03/19
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2025/03/19 IIPBS 최초 생성
|
||||
* </pre>
|
||||
*/
|
||||
@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));
|
||||
}
|
||||
}
|
||||
+67
@@ -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
|
||||
* <p>
|
||||
* Spring Security Config 클래스
|
||||
* ReactiveAuthorizationManager<AuthorizationContext> 구현체 ReactiveAuthorization 클래스를 통해 인증/인가 처리를 구현한다.
|
||||
*
|
||||
* @author 표준프레임워크센터 jaeyeolkim
|
||||
* @version 1.0
|
||||
* @since 2021/06/30
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2021/06/30 jaeyeolkim 최초 생성
|
||||
* </pre>
|
||||
*/
|
||||
@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> authentication, AuthorizationContext context)
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
@Bean
|
||||
public SecurityWebFilterChain configure(ServerHttpSecurity http, ReactiveAuthorizationManager<AuthorizationContext> 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();
|
||||
}
|
||||
|
||||
}
|
||||
+97
@@ -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
|
||||
* <p>
|
||||
* 에러 발생 시 에러 정보 중 필요한 내용만 반환한다
|
||||
* ErrorCode 에서 status, code, message 세 가지 속성을 의존한다
|
||||
*
|
||||
* @author 표준프레임워크센터 jaeyeolkim
|
||||
* @version 1.0
|
||||
* @since 2021/07/16
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2021/07/16 jaeyeolkim 최초 생성
|
||||
* 2024/09/24 이백행 컨트리뷰션 이클립스 문제(Problems) 해결
|
||||
* </pre>
|
||||
*/
|
||||
@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<String, Object> getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
|
||||
Map<String, Object> defaultMap = super.getErrorAttributes(request, options);
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.egovframe.cloud.apigateway.exception.dto;
|
||||
|
||||
/**
|
||||
* org.egovframe.cloud.common.exception.dto.ErrorCode
|
||||
* <p>
|
||||
* REST API 요청에 대한 오류 반환값을 정의
|
||||
* ErrorResponse 클래스에서 status, code, message 세 가지 속성을 의존한다
|
||||
* message 는 MessageSource 의 키 값을 정의하여 다국어 처리를 지원한다
|
||||
*
|
||||
* @author 표준프레임워크센터 jaeyeolkim
|
||||
* @version 1.0
|
||||
* @since 2021/07/16
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2021/07/16 jaeyeolkim 최초 생성
|
||||
* </pre>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<GlobalFilter.Config> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
*/
|
||||
+235
@@ -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
|
||||
* <p>
|
||||
* 요청 제한(Rate Limiting) 필터
|
||||
* 동일한 경로에 대한 과도한 요청을 제한하기 위한 필터
|
||||
*
|
||||
* @author changwng
|
||||
* @version 1.0
|
||||
* @since 2025/03/19
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2025/03/19 changwng 최초 생성
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class RateLimitingFilter extends AbstractGatewayFilterFactory<RateLimitingFilter.Config> {
|
||||
|
||||
// 요청 경로별 카운터 맵 (메모리 기반 구현)
|
||||
private final Map<String, PathCounter> 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<String, LocalDateTime> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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<Void> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>.*), /$\{segment}
|
||||
- id: board-service
|
||||
uri: lb://BOARD-SERVICE
|
||||
predicates:
|
||||
- Path=/board-service/**
|
||||
filters:
|
||||
- RewritePath=/board-service/(?<segment>.*), /$\{segment}
|
||||
- id: cms-service
|
||||
uri: lb://CMS-SERVICE
|
||||
predicates:
|
||||
- Path=/cms-service/**
|
||||
filters:
|
||||
- RewritePath=/cms-service/(?<segment>.*), /$\{segment}
|
||||
- id: attach-service
|
||||
uri: lb://ATTACH-SERVICE
|
||||
predicates:
|
||||
- Path=/attach-service/**
|
||||
filters:
|
||||
- RewritePath=/attach-service/(?<segment>.*), /$\{segment}
|
||||
- id: openapi
|
||||
uri: http://localhost:${server.port}
|
||||
predicates:
|
||||
- Path=/v3/api-docs/**
|
||||
filters:
|
||||
- RewritePath=/v3/api-docs/(?<segment>.*), /$\{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
|
||||
@@ -0,0 +1,5 @@
|
||||
spring:
|
||||
cloud:
|
||||
config:
|
||||
uri: http://localhost:8888
|
||||
name: application
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xml>
|
||||
<Configuration>
|
||||
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(%-4relative) - [%thread{10}, %X{traceId:-}, %X{spanId:-}] %cyan(%logger{20}): %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 로컬에서는 로그를 전송하지 않도록 설정 -->
|
||||
<springProfile name="default">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</springProfile>
|
||||
<springProfile name="!default">
|
||||
<!-- java -Ddestination="localhost:5001" 와 같이 변경할 수 있다. cf 환경에서는 manifest.yml 파일에 환경변수로 추가 -->
|
||||
<property name="destination" value="${logstash_hostname:-localhost:5001}" />
|
||||
<property name="app_name" value="${app_name:-apigateway}" />
|
||||
|
||||
<!-- ELK - Logstash 로 로그를 전송하기 위한 appender -->
|
||||
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
|
||||
<destination>${destination}</destination>
|
||||
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
|
||||
<providers>
|
||||
<mdc/>
|
||||
<context/>
|
||||
<logLevel/>
|
||||
<loggerName/>
|
||||
<pattern>
|
||||
<pattern>
|
||||
{"appName": "${app_name}"}
|
||||
</pattern>
|
||||
</pattern>
|
||||
<threadName/>
|
||||
<message/>
|
||||
<logstashMarkers/>
|
||||
<stackTrace/>
|
||||
</providers>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="WARN">
|
||||
<appender-ref ref="LOGSTASH" />
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
</Configuration>
|
||||
@@ -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>.*), /$\{segment}
|
||||
- id: board-service
|
||||
uri: lb://BOARD-SERVICE
|
||||
predicates:
|
||||
- Path=/board-service/**
|
||||
filters:
|
||||
- RewritePath=/board-service/(?<segment>.*), /$\{segment}
|
||||
- id: cms-service
|
||||
uri: lb://CMS-SERVICE
|
||||
predicates:
|
||||
- Path=/cms-service/**
|
||||
filters:
|
||||
- RewritePath=/cms-service/(?<segment>.*), /$\{segment}
|
||||
- id: attach-service
|
||||
uri: lb://ATTACH-SERVICE
|
||||
predicates:
|
||||
- Path=/attach-service/**
|
||||
filters:
|
||||
- RewritePath=/attach-service/(?<segment>.*), /$\{segment}
|
||||
- id: openapi
|
||||
uri: http://localhost:${server.port}
|
||||
predicates:
|
||||
- Path=/v3/api-docs/**
|
||||
filters:
|
||||
- RewritePath=/v3/api-docs/(?<segment>.*), /$\{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
|
||||
+15
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
+27
@@ -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("로그인");
|
||||
}
|
||||
}
|
||||
+34
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user