Merge branch 'master' into feats/ci-test
# Conflicts: # Jenkinsfile.deploy # Jenkinsfile.test-build # src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java
@@ -111,3 +111,5 @@ diff
|
||||
|
||||
*rinjae*
|
||||
*gf63*
|
||||
*obsidian*
|
||||
design-backup
|
||||
@@ -1,62 +1,51 @@
|
||||
pipeline {
|
||||
agent { label 'djb-vm' }
|
||||
|
||||
parameters {
|
||||
string(name: 'BRANCH', defaultValue: 'master', description: 'Branch to deploy')
|
||||
}
|
||||
agent none
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout() // stage별 agent의 자동 SCM checkout 방지 (Deploy 노드는 git 접근 불필요, unstash로만 WAR 수신)
|
||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Build (djb-vm)') {
|
||||
agent { label 'djb-vm' }
|
||||
environment {
|
||||
JAVA_HOME = '/apps/opts/jdk8'
|
||||
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
|
||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||
NODE_HOME = '/apps/opts/node-v24'
|
||||
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
CATALINA_BASE = '/prod/eapim/devportal'
|
||||
CATALINA_HOME = '/prod/eapim/apache-tomcat-9.0.116'
|
||||
CATALINA_PID = '/prod/eapim/devportal/temp/catalina.pid'
|
||||
DEPLOY_HTTP_PORT = '39130'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: "*/${params.BRANCH}"]],
|
||||
userRemoteConfigs: [[url: 'ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git']]
|
||||
])
|
||||
steps { checkout scm }
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout dependencies') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
cd "$WORKSPACE/.."
|
||||
|
||||
if [ ! -d elink-portal-common/.git ]; then
|
||||
rm -rf elink-portal-common
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
|
||||
elink-portal-common
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git elink-portal-common
|
||||
else
|
||||
git -C elink-portal-common fetch --depth=1 origin master
|
||||
git -C elink-portal-common reset --hard origin/master
|
||||
git -C elink-portal-common clean -fdx
|
||||
fi
|
||||
|
||||
mkdir -p eapim-online
|
||||
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
||||
rm -rf eapim-online/elink-online-core-jpa
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
|
||||
eapim-online/elink-online-core-jpa
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git eapim-online/elink-online-core-jpa
|
||||
else
|
||||
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||
@@ -65,116 +54,119 @@ pipeline {
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Verify toolchain') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
java -version
|
||||
gradle --version
|
||||
'''
|
||||
steps { sh 'set -eu; java -version; gradle --version' }
|
||||
}
|
||||
stage('Test') {
|
||||
steps { sh 'gradle clean test --no-daemon -Pprofile=weblogic' }
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build WAR') {
|
||||
steps {
|
||||
sh 'gradle clean build -x test --no-daemon'
|
||||
}
|
||||
steps { sh 'gradle build -x test --no-daemon -Pprofile=weblogic' }
|
||||
post {
|
||||
success {
|
||||
sh '''
|
||||
set -eu
|
||||
cd build/libs
|
||||
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
|
||||
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
|
||||
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
|
||||
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Stop Tomcat') {
|
||||
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
|
||||
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
|
||||
stage('SBOM') {
|
||||
steps {
|
||||
sh '''
|
||||
set +e
|
||||
systemctl --user stop eapim-portal 2>/dev/null
|
||||
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||
sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null; then
|
||||
JAVA_HOME="$JAVA_HOME_TOMCAT" "$CATALINA_HOME/bin/shutdown.sh" 30 -force || true
|
||||
for i in $(seq 1 30); do
|
||||
[ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
rm -f "$CATALINA_PID"
|
||||
stage('Deploy (weblogic)') {
|
||||
agent { label 'weblogic' }
|
||||
environment {
|
||||
JENKINS_NODE_COOKIE = 'dontKillMe' // background weblogic를 빌드 종료 시 죽이지 않게
|
||||
WL_HOME = '/app/eapim/devportal'
|
||||
WL_DEPLOY_DIR = '/app/eapim/devportal'
|
||||
WL_WAR_NAME = 'eapim-portal.war'
|
||||
WL_HTTP_PORT = '39130'
|
||||
WL_NOHUP = '/logs/weblogic/domains/eapimDomain/nohup.devSvr11.out'
|
||||
}
|
||||
stages {
|
||||
stage('Stop WebLogic') {
|
||||
steps {
|
||||
sh '"$WL_HOME/stopDev11.sh"' // 동기: 완전 종료까지 블록, 미기동 시에도 안전
|
||||
}
|
||||
}
|
||||
stage('Deploy WAR') {
|
||||
steps {
|
||||
unstash 'war'
|
||||
sh '''
|
||||
set -eu
|
||||
cp build/libs/eapim-portal.war "$WL_DEPLOY_DIR/$WL_WAR_NAME"
|
||||
ls -la "$WL_DEPLOY_DIR"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy ROOT.war') {
|
||||
stage('Start WebLogic and readiness') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
DEPLOY_DIR="$CATALINA_BASE/webapps"
|
||||
rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
|
||||
cp build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
|
||||
ls -la "$DEPLOY_DIR"
|
||||
'''
|
||||
}
|
||||
}
|
||||
"$WL_HOME/startDev11.sh" # nohup & 로 백그라운드 기동, 즉시 리턴
|
||||
|
||||
stage('Start Tomcat and readiness') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
|
||||
BEFORE=0
|
||||
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
|
||||
|
||||
systemctl --user start eapim-portal
|
||||
|
||||
DEADLINE=$(($(date +%s) + 120))
|
||||
LIVE_OK=0
|
||||
DEADLINE=$(($(date +%s) + 300))
|
||||
STATUS=000
|
||||
|
||||
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||
if [ "$LIVE_OK" = "0" ]; then
|
||||
LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||
"http://localhost:$DEPLOY_HTTP_PORT/health" 2>/dev/null || echo "000")
|
||||
if [ "$LIVE" = "200" ]; then
|
||||
echo "Liveness OK (/health)"
|
||||
LIVE_OK=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$LIVE_OK" = "1" ]; then
|
||||
STATUS=$(curl -sS -o /tmp/eapim-ready-body.json -w '%{http_code}' --max-time 3 \
|
||||
"http://localhost:$DEPLOY_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Readiness OK (/health/ready)"
|
||||
cat /tmp/eapim-ready-body.json
|
||||
echo
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | grep -qE "Application listener .* Exception|SEVERE.*startup.Catalina"; then
|
||||
echo "Startup error detected in log"
|
||||
tail -c +$((BEFORE+1)) "$LOG" | tail -80
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||
"http://localhost:$WL_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
|
||||
[ "$STATUS" = "200" ] && { echo "Readiness OK (/health/ready)"; break; }
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "Readiness probe failed within 120s, last HTTP status: $STATUS"
|
||||
[ -f /tmp/eapim-ready-body.json ] && cat /tmp/eapim-ready-body.json && echo
|
||||
[ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -100
|
||||
echo "Readiness failed within 300s, last HTTP=$STATUS"
|
||||
[ -f "$WL_NOHUP" ] && tail -120 "$WL_NOHUP"
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "--- key boot log lines ---"
|
||||
tail -c +$((BEFORE+1)) "$LOG" | grep -E "Server startup|Deployment of web" | head -10 || true
|
||||
post {
|
||||
success {
|
||||
node('weblogic') {
|
||||
sh '''
|
||||
set +e
|
||||
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
||||
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
||||
'''
|
||||
}
|
||||
}
|
||||
failure {
|
||||
node('weblogic') {
|
||||
sh '''
|
||||
set +e
|
||||
payload=$(printf '{"text":"[%s #%s](%s) FAILURE"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
||||
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,10 +91,28 @@ pipeline {
|
||||
sh '''
|
||||
set -eu
|
||||
cd build/libs
|
||||
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
|
||||
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
|
||||
for f in eapim-portal.war eapim-portal-boot.war; do
|
||||
sha1sum "$f" > "$f.sha1"
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
md5sum "$f" > "$f.md5"
|
||||
done
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
|
||||
// 실패해도 빌드는 진행하도록 UNSTABLE 로만 표시한다.
|
||||
stage('SBOM') {
|
||||
steps {
|
||||
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||
sh 'gradle sbomXlsx --no-daemon'
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
|
||||
## 문서
|
||||
|
||||
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
|
||||
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
|
||||
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
||||
- **사용자 가이드**: `개발자포탈.md` (한글)
|
||||
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
|
||||
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
|
||||
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
|
||||
|
||||
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
|
||||
|
||||
|
||||
echo "========================"
|
||||
java -version
|
||||
javac -version
|
||||
echo "========================"
|
||||
gradle --version
|
||||
echo "========================"
|
||||
|
||||
gradle clean build -x test --no-daemon
|
||||
@@ -3,6 +3,7 @@ plugins {
|
||||
id 'war'
|
||||
id 'eclipse'
|
||||
id 'idea'
|
||||
id 'org.cyclonedx.bom' version '3.2.4'
|
||||
id 'org.springframework.boot' version '2.7.18'
|
||||
id 'io.spring.dependency-management' version '1.1.3'
|
||||
}
|
||||
@@ -23,15 +24,36 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 전용 라이브러리 — WebLogic 배포 산출물(war/bootWar)에서 제외한다.
|
||||
// 정보보호 점검에서 Actuator/모니터링 라이브러리가 배포본에 실려 있으면 불필요하게 탐지되므로
|
||||
// 로컬(bootRun/IDE)에서만 classpath 에 오르게 한다. 제외 로직은 아래 war/bootWar 블록.
|
||||
//
|
||||
// ※ Spring Boot 의 developmentOnly 를 쓰지 않는 이유
|
||||
// developmentOnly 는 bootJar/bootWar 에서만 제외된다. 표준 war task 는 runtimeClasspath 를
|
||||
// 그대로 쓰므로 산출물에 실린다 (실측: devtools 가 eapim-portal.war 에 포함되어 있었음).
|
||||
// ※ 별도 configuration(localOnly)을 쓰지 않는 이유
|
||||
// runtimeClasspath 에서 빠지면 IntelliJ 가 모듈 classpath 를 구성할 때도 빠져
|
||||
// IDE 로 기동할 때 Actuator/SBA 가 동작하지 않는다.
|
||||
def localOnlyLibPrefixes = [
|
||||
'spring-boot-devtools',
|
||||
'spring-boot-starter-actuator',
|
||||
'spring-boot-actuator', // spring-boot-actuator, -autoconfigure 모두 매칭
|
||||
'micrometer-', // actuator 전용(runtimeClasspath 상 다른 출처 없음 — 확인함)
|
||||
'spring-boot-admin-',
|
||||
]
|
||||
|
||||
dependencies {
|
||||
annotationProcessor "org.projectlombok:lombok:1.18.28"
|
||||
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0", "org.mapstruct:mapstruct-processor:1.5.5.Final"
|
||||
|
||||
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
|
||||
|
||||
// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
|
||||
// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
|
||||
implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
|
||||
// OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
|
||||
// swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
|
||||
// 아직 swagger-core/parser 어느 버전도 미지원. 지원 추가 시 버전만 상향하면 됨. JDK8 호환.
|
||||
implementation 'io.swagger.core.v3:swagger-core:2.2.52' // io.swagger.v3.oas.models.*, io.swagger.v3.core.util.Json
|
||||
implementation 'io.swagger.parser.v3:swagger-parser:2.1.45' // io.swagger.parser.OpenAPIParser, io.swagger.v3.parser.*
|
||||
implementation 'io.swagger:swagger-annotations:1.6.14' // io.swagger.annotations.ApiModelProperty (CommissionSearch)
|
||||
|
||||
implementation project(':elink-online-core-jpa')
|
||||
implementation project(':elink-portal-common')
|
||||
@@ -41,6 +63,23 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jta-atomikos'
|
||||
implementation('org.springframework.boot:spring-boot-starter-web')
|
||||
implementation('org.springframework.boot:spring-boot-starter-validation')
|
||||
|
||||
// ↓ 로컬 전용. war/bootWar 산출물에서는 localOnlyLibPrefixes 로 제외된다.
|
||||
// runtimeOnly 인 이유: compileClasspath 에서 빠지므로 자바 코드가 이 API 를 참조하면
|
||||
// 컴파일 단계에서 막힌다. implementation 이면 참조가 컴파일에 통과해버리고,
|
||||
// 배포본(actuator/SBA 제외됨)에서 NoClassDefFoundError 로 터진다.
|
||||
// IntelliJ / bootRun 은 runtimeClasspath 기준이라 로컬 기동에는 정상 포함된다.
|
||||
// 설정(application-local*.yml)으로만 사용한다.
|
||||
runtimeOnly('org.springframework.boot:spring-boot-starter-actuator')
|
||||
// Spring Boot Admin client. 2.7.16 = Spring Boot 2.7.x 대응 마지막 계열(Java 8 호환).
|
||||
runtimeOnly('de.codecentric:spring-boot-admin-starter-client:2.7.16')
|
||||
// 위 runtimeOnly 원칙의 유일한 예외. InterceptorsEndpointConfig 가 actuator API
|
||||
// (@Endpoint / InfoContributor)를 참조해야 하므로 compileClasspath 에만 올린다.
|
||||
// compileOnly 는 runtimeClasspath 에 포함되지 않으므로 war/bootWar 산출물에는 영향이 없다.
|
||||
// 해당 클래스는 @ConditionalOnClass 로 actuator 부재 시 로드되지 않으니
|
||||
// 배포본(actuator 제외)에서 NoClassDefFoundError 가 나지 않는다.
|
||||
compileOnly('org.springframework.boot:spring-boot-actuator')
|
||||
|
||||
implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.0'
|
||||
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
|
||||
|
||||
@@ -76,9 +115,10 @@ dependencies {
|
||||
// exclude group: 'commons-collections', module: 'commons-collections'
|
||||
}
|
||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.3'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
|
||||
// WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
|
||||
implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
|
||||
|
||||
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
|
||||
|
||||
@@ -124,7 +164,16 @@ bootRun {
|
||||
// jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005'
|
||||
|
||||
sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조)
|
||||
args = ["--spring.profiles.active=dev"]
|
||||
|
||||
// 로컬 기동 기본 프로파일.
|
||||
// dev/stage/prod 는 리버스프록시(OHS) 뒤 WAR 배포용이라 server.forward-headers-strategy=framework 가
|
||||
// 걸려 있다. framework 는 신뢰 프록시 목록 없이 X-Forwarded-* 를 그대로 신뢰하므로 앞단이 없는
|
||||
// 로컬 기동에는 쓰지 않는다(로컬은 공통 기본값 native = Tomcat RemoteIpValve, 사설대역만 신뢰).
|
||||
// 다른 프로파일로 띄우려면: gradle bootRun -PbootProfile=dev
|
||||
// ('profile' 이 아니라 'bootProfile' 인 이유: 위 ext 블록이 profile='local' 을 이미 점유하고 있어
|
||||
// findProperty('profile') 은 -P 지정 여부와 무관하게 항상 'local' 을 돌려준다.)
|
||||
def bootRunProfile = (project.findProperty('bootProfile') ?: 'local_rinjaemac').toString()
|
||||
args = ["--spring.profiles.active=" + bootRunProfile]
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +190,14 @@ sourceSets {
|
||||
|
||||
configurations {
|
||||
annotationProcessor
|
||||
|
||||
// WebLogic 배포 시 Tyrus WebSocket 필터(weblogic.websocket.tyrus.TyrusServletFilter)와
|
||||
// 충돌 방지: WAR 에 번들된 Tomcat WsSci 가 javax.websocket.server.ServerContainer 속성을
|
||||
// WsServerContainer 로 등록 → WebLogic Tyrus 필터가 TyrusServerContainer 로 캐스팅하다 실패.
|
||||
// 앱은 WebSocket 미사용이므로 Tomcat WebSocket 모듈 제외.
|
||||
all {
|
||||
exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-websocket'
|
||||
}
|
||||
}
|
||||
|
||||
compileJava {
|
||||
@@ -150,11 +207,11 @@ compileJava {
|
||||
}
|
||||
|
||||
processResources {
|
||||
exclude { details ->
|
||||
details.file.name.startsWith('application-') &&
|
||||
details.file.name.endsWith('.yml') &&
|
||||
!(details.file.name in ['application-stage.yml', 'application-prod.yml'])
|
||||
}
|
||||
// exclude { details ->
|
||||
// details.file.name.startsWith('application-') &&
|
||||
// details.file.name.endsWith('.yml') &&
|
||||
// !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
|
||||
// }
|
||||
}
|
||||
|
||||
test {
|
||||
@@ -168,8 +225,21 @@ test {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
// 로컬 전용 설정 파일. 배포 산출물(WAR)에 실리면 Actuator/SBA 설정이 그대로 노출되어
|
||||
// 정보보호 점검에 불필요하게 걸린다. processResources 는 건드리지 않는다
|
||||
// (bootRun 이 build/resources/main 을 그대로 쓰므로 로컬 기동이 깨진다).
|
||||
def localOnlyResources = ['**/application-local*.yml']
|
||||
|
||||
// 배포 산출물에서 로컬 전용 라이브러리를 걸러낸다. localOnlyLibPrefixes 는 파일 상단 정의.
|
||||
def excludeLocalOnlyLibs = { org.gradle.api.file.FileCollection cp ->
|
||||
cp.filter { f -> !localOnlyLibPrefixes.any { p -> f.name.startsWith(p) } }
|
||||
}
|
||||
|
||||
bootWar {
|
||||
archiveFileName = "eapim-portal-boot.war"
|
||||
mainClass = 'com.eactive.apim.portal.PortalApplication'
|
||||
rootSpec.exclude(localOnlyResources)
|
||||
classpath = excludeLocalOnlyLibs(classpath)
|
||||
}
|
||||
|
||||
war {
|
||||
@@ -178,6 +248,8 @@ war {
|
||||
from('src/main/resources/jeus-web-dd.xml') { into 'WEB-INF' }
|
||||
from('src/main/resources/weblogic.xml') { into 'WEB-INF' }
|
||||
|
||||
rootSpec.exclude(localOnlyResources)
|
||||
classpath = excludeLocalOnlyLibs(classpath)
|
||||
}
|
||||
|
||||
task printSourceSets {
|
||||
@@ -190,3 +262,5 @@ task printSourceSets {
|
||||
}
|
||||
}
|
||||
}
|
||||
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
|
||||
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
./gradlew bootWar
|
||||
docker build -t eactive-portal .
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the war file is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 /path/to/DEVPortal.war"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
WAR_FILE="$1"
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
DEST_DIR="/app/apim/deploy/DVPWeb"
|
||||
BACKUP_DIR="/app/apim/backup"
|
||||
|
||||
# Create backup directory if not exists
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup the existing directory
|
||||
if [ -d "$DEST_DIR" ]; then
|
||||
echo "Backing up existing directory..."
|
||||
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
# Create destination directory if not exists
|
||||
mkdir -p $DEST_DIR
|
||||
|
||||
# Unzip the file to the dedicated location
|
||||
echo "Extracting WAR file to $DEST_DIR..."
|
||||
unzip $WAR_FILE -d $DEST_DIR
|
||||
|
||||
echo "deploy successful"
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the war file is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 /path/to/DEVPortal.war"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
WAR_FILE="$1"
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
DEST_DIR="/app/apim/deploy/DVPWeb"
|
||||
STATIC_DIR="/app/apim/deploy/static"
|
||||
BACKUP_DIR="/app/apim/backup"
|
||||
|
||||
# Create backup directory if not exists
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup the existing directory
|
||||
if [ -d "$STATIC_DIR" ]; then
|
||||
echo "Backing up existing static directory..."
|
||||
mv $STATIC_DIR "${BACKUP_DIR}/dvp_static_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
if [ -d "$DEST_DIR" ]; then
|
||||
echo "Backing up existing directory..."
|
||||
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
|
||||
# Create destination directory if not exists
|
||||
mkdir -p $DEST_DIR
|
||||
mkdir -p $STATIC_DIR
|
||||
|
||||
# unzip the file to the dedicated location
|
||||
echo "Extracting WAR file to $DEST_DIR..."
|
||||
unzip $WAR_FILE -d $DEST_DIR
|
||||
mv $DEST_DIR/static/* $STATIC_DIR/
|
||||
|
||||
echo "deploy successful"
|
||||
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>DJBank API Portal 이메일 인증</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
table, td { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin:0;padding:0;width:100%;background-color:#eef0f3;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<!-- 미리보기 텍스트 (받은편지함 목록에 노출, 본문에서는 숨김) -->
|
||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#eef0f3;">DJBank 개발자포탈 이메일 인증번호 안내입니다.</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#eef0f3;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 12px;">
|
||||
|
||||
<!-- ============ 카드 (흰 배경 · 테두리 #dfdfdf · 라운드 20) ============ -->
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid #dfdfdf;border-radius:20px;">
|
||||
|
||||
<!-- ===== Header : DJ Bank 로고 + API Portal ===== -->
|
||||
<tr>
|
||||
<td style="padding:26px 27px 0 27px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="20" style="width:20px;font-size:0;line-height:0;"> </td>
|
||||
<td valign="middle" style="line-height:0;">
|
||||
<img src="images/logo-djbank.png" width="144" height="40" alt="DJ Bank" style="display:block;width:144px;height:40px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
|
||||
</td>
|
||||
<td width="16" style="width:16px;font-size:0;line-height:0;"> </td>
|
||||
<td valign="middle" style="line-height:0;">
|
||||
<img src="images/logo-apiportal.png" width="124" height="21" alt="API Portal" style="display:block;width:124px;height:21px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ===== 본문 : 상단 파란선(#2A69DE) + 그림자 ===== -->
|
||||
<tr>
|
||||
<td style="padding:7px 27px 0 27px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-top:3px solid #2a69de;box-shadow:0px 4px 12px 0px rgba(85,95,108,0.25);">
|
||||
<tr>
|
||||
<td style="padding:26px 29px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
|
||||
<!-- 제목 (subject) : ##변수## 치환 토큰 -->
|
||||
<tr>
|
||||
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:15px;line-height:25px;color:#000000;font-weight:600;padding-bottom:20px;">##subject:이메일인증##</td>
|
||||
</tr>
|
||||
|
||||
<!-- 인증번호 박스 (#ECF1FF · 라운드 10) -->
|
||||
<tr>
|
||||
<td style="padding-bottom:20px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#ecf1ff;border-radius:10px;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ecf1ff" style="background-color:#ecf1ff;border-radius:10px;padding:16px 20px;font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:14px;line-height:24px;color:#000000;font-weight:700;text-align:center;letter-spacing:2px;"><!-- 동적값: 인증번호 -->123456</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 안내 문구 -->
|
||||
<tr>
|
||||
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:12px;line-height:25px;color:#000000;padding-bottom:20px;">
|
||||
안녕하세요, <!-- 동적값: 회원명 -->김철수 님.<br>
|
||||
DJBank 개발자포탈 회원가입 시 입력하신 이메일을 인증해 주세요.<br>
|
||||
아래 인증번호를 회원가입 화면에 입력하시면 인증이 완료됩니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 경고 문구 (#FF2727) -->
|
||||
<tr>
|
||||
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:12px;line-height:25px;color:#ff2727;font-weight:600;">※ 본 메일을 요청하지 않으셨다면 이 메일을 무시해 주시기 바랍니다.</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ===== Footer : 제주은행 로고 + 안내 (#F1F1ED) ===== -->
|
||||
<tr>
|
||||
<td style="padding:7px 27px 26px 27px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f1f1ed" style="background-color:#f1f1ed;border-radius:4px;">
|
||||
<tr>
|
||||
<td style="padding:12px 14px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="middle" width="86" style="width:86px;padding-right:16px;line-height:0;">
|
||||
<img src="images/logo-jejubank.png" width="86" height="24" alt="제주은행" style="display:block;width:86px;height:24px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
|
||||
</td>
|
||||
<td valign="middle" style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;color:#0d0e11;">
|
||||
<p style="margin:0;font-size:10px;line-height:20px;color:#0d0e11;">본 메일은 발신 전용으로 회신되지 않습니다.</p>
|
||||
<p style="margin:0 0 9px 0;font-size:10px;line-height:20px;color:#0d0e11;">관련 문의사항은 고객센터 (????-????) 또는 홈페이지를 이용하시기 바랍니다.</p>
|
||||
<p style="margin:0 0 9px 0;font-size:10px;line-height:16px;color:#0d0e11;">제주 제주시 1100로 3351 (노형동)<span style="color:#c2c2bc;"> | </span>고객센터 : ????-?????<span style="color:#c2c2bc;"> | </span>사업자 등록번호 : 616-81-00615</p>
|
||||
<p style="margin:0;font-size:10px;line-height:16px;color:#454545;font-weight:500;">Copyright JEJUBANK. ALL Rights Reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- ============ /카드 ============ -->
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
|
||||
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
|
||||
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
|
||||
|
||||
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
|
||||
|
||||
|
||||
# gradle 명령을 대체합니다.
|
||||
command gradle $@
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
|
||||
*
|
||||
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
|
||||
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
|
||||
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
|
||||
*
|
||||
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
|
||||
* classpath 나 WAR 산출물에는 포함되지 않는다.
|
||||
*
|
||||
* 시트: 요약 / WAR 기준
|
||||
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
|
||||
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
|
||||
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
|
||||
*/
|
||||
buildscript {
|
||||
repositories {
|
||||
maven {
|
||||
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
|
||||
allowInsecureProtocol = true
|
||||
}
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'org.apache.poi:poi-ooxml:3.17'
|
||||
}
|
||||
}
|
||||
|
||||
import groovy.json.JsonSlurper
|
||||
import org.apache.poi.ss.usermodel.BorderStyle
|
||||
import org.apache.poi.ss.usermodel.FillPatternType
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment
|
||||
import org.apache.poi.ss.usermodel.IndexedColors
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment
|
||||
import org.apache.poi.ss.util.CellRangeAddress
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
|
||||
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
|
||||
ext.SBOM_CELL_LIMIT = 32000
|
||||
|
||||
task sbomXlsx {
|
||||
group = 'sbom'
|
||||
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
|
||||
|
||||
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
|
||||
if (!project.hasProperty('sbomJson')) {
|
||||
dependsOn 'cyclonedxBom'
|
||||
}
|
||||
|
||||
doLast {
|
||||
File src = resolveBomJson(project)
|
||||
File out = project.hasProperty('sbomOut')
|
||||
? project.file(project.property('sbomOut'))
|
||||
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
|
||||
out.parentFile.mkdirs()
|
||||
|
||||
def bom = new JsonSlurper().parse(src, 'UTF-8')
|
||||
def deploy = collectDeployJars(project)
|
||||
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
|
||||
|
||||
def wb = new XSSFWorkbook()
|
||||
def st = createStyles(wb)
|
||||
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
|
||||
writeWarSheet(wb, st, warRows)
|
||||
|
||||
out.withOutputStream { os -> wb.write(os) }
|
||||
wb.close()
|
||||
|
||||
int unmatched = warRows.count { it.matched == 'N' }
|
||||
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
|
||||
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
|
||||
}
|
||||
}
|
||||
|
||||
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
|
||||
String sbomFileName(Project p) {
|
||||
def warTask = p.tasks.findByName('war')
|
||||
if (warTask != null) {
|
||||
String archive = warTask.archiveFileName.get()
|
||||
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
|
||||
}
|
||||
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
|
||||
return "${p.name}${ver}-sbom.xlsx"
|
||||
}
|
||||
|
||||
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
|
||||
File resolveBomJson(Project p) {
|
||||
if (p.hasProperty('sbomJson')) {
|
||||
File f = p.file(p.property('sbomJson'))
|
||||
if (!f.exists()) {
|
||||
throw new GradleException("bom.json 없음: ${f.absolutePath}")
|
||||
}
|
||||
return f
|
||||
}
|
||||
def candidates = [
|
||||
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
|
||||
new File(p.buildDir, 'reports/bom.json'),
|
||||
]
|
||||
File found = candidates.find { it.exists() }
|
||||
if (found == null) {
|
||||
throw new GradleException(
|
||||
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
|
||||
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 배포물에 packaging 되는 jar 목록.
|
||||
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
|
||||
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
|
||||
*
|
||||
* @return [label: 기준 설명, jars: 행 목록]
|
||||
*/
|
||||
Map collectDeployJars(Project p) {
|
||||
def cfg = p.configurations.findByName('runtimeClasspath')
|
||||
if (cfg == null) {
|
||||
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
|
||||
return [label: '(없음)', jars: []]
|
||||
}
|
||||
|
||||
def warTask = p.tasks.findByName('war')
|
||||
def files
|
||||
String label
|
||||
if (warTask != null) {
|
||||
files = warTask.classpath.files
|
||||
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
|
||||
} else {
|
||||
files = cfg.files
|
||||
label = 'runtimeClasspath (war 태스크 없음)'
|
||||
}
|
||||
|
||||
def coordByFile = [:]
|
||||
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
|
||||
def id = a.moduleVersion.id
|
||||
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
|
||||
}
|
||||
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
|
||||
def c = coordByFile[f]
|
||||
[
|
||||
file : f.name,
|
||||
group : c?.group ?: '',
|
||||
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
|
||||
version: c?.version ?: '',
|
||||
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
|
||||
]
|
||||
}.sort { it.file }
|
||||
|
||||
return [label: label, jars: jars]
|
||||
}
|
||||
|
||||
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
|
||||
Map indexComponents(bom) {
|
||||
String rootRef = bom.metadata?.component?.'bom-ref'
|
||||
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
|
||||
|
||||
def index = [:]
|
||||
bom.components?.each { c ->
|
||||
def hashes = [:]
|
||||
c.hashes?.each { h -> hashes[h.alg] = h.content }
|
||||
|
||||
def licenses = (c.licenses ?: []).collect { l ->
|
||||
l.license?.id ?: l.license?.name ?: l.expression ?: ''
|
||||
}.findAll { it }
|
||||
|
||||
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
|
||||
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
|
||||
licenses : licenses.join('; '),
|
||||
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
|
||||
purl : c.purl ?: '',
|
||||
sha256 : hashes['SHA-256'] ?: '',
|
||||
sha1 : hashes['SHA-1'] ?: '',
|
||||
description: c.description ?: '',
|
||||
]
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
|
||||
List joinWarRows(List warJars, Map index) {
|
||||
def result = []
|
||||
warJars.eachWithIndex { j, i ->
|
||||
def m = j.coord ? index[j.coord] : null
|
||||
result << [
|
||||
no : i + 1,
|
||||
file : j.file,
|
||||
group : j.group,
|
||||
name : j.name,
|
||||
version : j.version,
|
||||
direct : m?.direct ?: '',
|
||||
licenses : m?.licenses ?: '',
|
||||
licenseList: m?.licenseList ?: ['(미상)'],
|
||||
purl : m?.purl ?: '',
|
||||
sha256 : m?.sha256 ?: '',
|
||||
sha1 : m?.sha1 ?: '',
|
||||
matched : (m != null) ? 'Y' : 'N',
|
||||
description: m?.description ?: '',
|
||||
]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Map createStyles(wb) {
|
||||
def headFont = wb.createFont()
|
||||
headFont.setBold(true)
|
||||
headFont.setColor(IndexedColors.WHITE.getIndex())
|
||||
|
||||
def head = wb.createCellStyle()
|
||||
head.setFont(headFont)
|
||||
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
|
||||
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
|
||||
head.setAlignment(HorizontalAlignment.CENTER)
|
||||
head.setVerticalAlignment(VerticalAlignment.CENTER)
|
||||
head.setBorderBottom(BorderStyle.THIN)
|
||||
|
||||
def body = wb.createCellStyle()
|
||||
body.setVerticalAlignment(VerticalAlignment.TOP)
|
||||
|
||||
def wrap = wb.createCellStyle()
|
||||
wrap.setVerticalAlignment(VerticalAlignment.TOP)
|
||||
wrap.setWrapText(true)
|
||||
|
||||
def labelFont = wb.createFont()
|
||||
labelFont.setBold(true)
|
||||
def label = wb.createCellStyle()
|
||||
label.setFont(labelFont)
|
||||
|
||||
return [head: head, body: body, wrap: wrap, label: label]
|
||||
}
|
||||
|
||||
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
|
||||
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
|
||||
def row = sheet.createRow(0)
|
||||
row.setHeightInPoints(20f)
|
||||
headers.eachWithIndex { h, i ->
|
||||
def cell = row.createCell(i)
|
||||
cell.setCellValue(h)
|
||||
cell.setCellStyle(style)
|
||||
sheet.setColumnWidth(i, widths[i] * 256)
|
||||
}
|
||||
sheet.createFreezePane(0, 1)
|
||||
}
|
||||
|
||||
def cellOf(row, int idx, value, style) {
|
||||
def cell = row.createCell(idx)
|
||||
String s = (value == null) ? '' : value.toString()
|
||||
if (s.length() > SBOM_CELL_LIMIT) {
|
||||
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
|
||||
}
|
||||
cell.setCellValue(s)
|
||||
cell.setCellStyle(style)
|
||||
return cell
|
||||
}
|
||||
|
||||
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
|
||||
def sheet = wb.createSheet('요약')
|
||||
def comp = bom.metadata?.component ?: [:]
|
||||
def tool = bom.metadata?.tools?.components?.getAt(0)
|
||||
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
|
||||
|
||||
def items = [
|
||||
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
|
||||
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
|
||||
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
|
||||
['serialNumber', bom.serialNumber ?: ''],
|
||||
['생성 시각', bom.metadata?.timestamp ?: ''],
|
||||
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
|
||||
['원본 파일', src.absolutePath],
|
||||
['배포 수록 jar', warRows.size()],
|
||||
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
|
||||
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
|
||||
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
|
||||
['라이선스 종류', licenseKinds.size()],
|
||||
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
|
||||
]
|
||||
|
||||
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
|
||||
items.eachWithIndex { item, i ->
|
||||
def row = sheet.createRow(i + 1)
|
||||
cellOf(row, 0, item[0], st.label)
|
||||
cellOf(row, 1, item[1], st.body)
|
||||
}
|
||||
|
||||
// 라이선스 분포 (요약 하단)
|
||||
def byLicense = [:].withDefault { 0 }
|
||||
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
|
||||
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
|
||||
|
||||
int base = items.size() + 2
|
||||
def hdr = sheet.createRow(base)
|
||||
cellOf(hdr, 0, '라이선스', st.head)
|
||||
cellOf(hdr, 1, 'jar 수', st.head)
|
||||
sorted.eachWithIndex { e, i ->
|
||||
def row = sheet.createRow(base + 1 + i)
|
||||
cellOf(row, 0, e.key, st.body)
|
||||
cellOf(row, 1, e.value, st.body)
|
||||
}
|
||||
}
|
||||
|
||||
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
|
||||
def writeWarSheet(wb, st, List warRows) {
|
||||
def sheet = wb.createSheet('WAR 기준')
|
||||
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
|
||||
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
|
||||
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
|
||||
writeHeader(sheet, st.head, headers, widths)
|
||||
|
||||
warRows.eachWithIndex { r, i ->
|
||||
def row = sheet.createRow(i + 1)
|
||||
cellOf(row, 0, r.no, st.body)
|
||||
cellOf(row, 1, r.file, st.body)
|
||||
cellOf(row, 2, r.group, st.body)
|
||||
cellOf(row, 3, r.name, st.body)
|
||||
cellOf(row, 4, r.version, st.body)
|
||||
cellOf(row, 5, r.direct, st.body)
|
||||
cellOf(row, 6, r.licenses, st.body)
|
||||
cellOf(row, 7, r.purl, st.body)
|
||||
cellOf(row, 8, r.sha256, st.body)
|
||||
cellOf(row, 9, r.sha1, st.body)
|
||||
cellOf(row, 10, r.matched, st.body)
|
||||
cellOf(row, 11, r.description, st.wrap)
|
||||
}
|
||||
if (!warRows.isEmpty()) {
|
||||
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# 메뉴 관리 개발 가이드
|
||||
|
||||
포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
|
||||
노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
|
||||
|
||||
## 구성 요소
|
||||
|
||||
| 구성 | 위치 | 역할 |
|
||||
|---|---|---|
|
||||
| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
|
||||
| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
|
||||
| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
|
||||
| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
|
||||
| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
|
||||
| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
|
||||
| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
|
||||
| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
|
||||
|
||||
메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
|
||||
`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
|
||||
|
||||
## menu.yml 스키마
|
||||
|
||||
```yaml
|
||||
portal-menu:
|
||||
items:
|
||||
- id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
|
||||
name: "고객지원"
|
||||
group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
|
||||
section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
|
||||
expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
|
||||
children:
|
||||
- { id: support-faq, name: "FAQ", path: /faq_list }
|
||||
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
|
||||
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
|
||||
```
|
||||
|
||||
- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
|
||||
- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
|
||||
- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
|
||||
|
||||
## 시딩 규칙 (MenuSeeder)
|
||||
|
||||
1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
|
||||
**관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
|
||||
구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
|
||||
2. **배치**: `PTL_MENU_PLACEMENT` 가 **비어있을 때만** 기본 배치로 최초 시딩.
|
||||
이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
|
||||
3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
|
||||
4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
|
||||
|
||||
## 캐시와 리로드
|
||||
|
||||
- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
|
||||
- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
|
||||
(admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
|
||||
- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
|
||||
+ X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
|
||||
- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
|
||||
|
||||
## 새 메뉴 추가 절차
|
||||
|
||||
**기본 메뉴(코드 배포와 함께)**
|
||||
1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
|
||||
2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
|
||||
3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
|
||||
4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
|
||||
admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
|
||||
|
||||
**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
|
||||
커스텀 항목은 미배치 시 삭제된다.
|
||||
|
||||
## 로컬 개발 주의
|
||||
|
||||
- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
|
||||
`JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
|
||||
(미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
|
||||
- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
|
||||
- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
|
||||
|
||||
## 역할(roles.yml) 변경
|
||||
|
||||
- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
|
||||
admin 권한 선택 체크박스 소스 전용.
|
||||
- 역할 추가 시 `roles.yml` 의 `authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
|
||||
@@ -0,0 +1,112 @@
|
||||
# DJB 커스텀 소스 납품본 수집
|
||||
|
||||
발주사 납품용으로, 기준 시점 이후 **신규로 추가된 커스텀 소스 파일**만 모아
|
||||
디렉터리 구조를 유지한 채 한곳에 복사하고 `MANIFEST.csv` / `SUMMARY.md` / zip 을 만든다.
|
||||
|
||||
## 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `export-custom.sh` | macOS / Linux 실행 스크립트 |
|
||||
| `export-custom.ps1` | Windows PowerShell 실행 스크립트 (실제 로직) |
|
||||
| `export-custom.bat` | Windows cmd 래퍼 — 인자를 `.ps1` 로 그대로 전달 |
|
||||
| `modules.conf` | 대상 모듈 / 소스 경로 / 수집 확장자 |
|
||||
| `export-filter.rules` | 포함·제외 규칙 (`.gitignore` 유사 문법) |
|
||||
| `filelist.txt` | 마지막으로 뽑은 수집 대상 목록 (검토용, 재생성 가능) |
|
||||
| `filelist-no-djb-marker.txt` | 그중 `djb` / `custom` 마커가 **없는** 파일 — 업그레이드 유입분 검토 대상 |
|
||||
|
||||
## 수집 방식
|
||||
|
||||
1. 모듈별로 **컷오프 날짜 직전의 마지막 커밋**을 base 로 잡는다.
|
||||
`git rev-list -1 --before=<CUTOFF> HEAD`
|
||||
컷오프 이전 커밋이 아예 없으면 저장소 전체를 신규로 본다.
|
||||
2. `git diff --name-only --diff-filter=A -M <base> HEAD -- <소스경로>` 로
|
||||
**신규 추가(A)** 파일만 뽑는다. `-M` 이라 단순 rename 은 신규로 잡히지 않는다.
|
||||
3. `modules.conf` 의 확장자 allowlist 로 거른다. (확장자 없는 파일은 제외)
|
||||
4. `export-filter.rules` 로 포함·제외를 최종 결정한다.
|
||||
5. 남은 파일을 `<out>/src/<모듈>/<원래 경로>` 로 복사한다.
|
||||
|
||||
## 사용법
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
./export-custom.sh --dry-run > filelist.txt # 목록만 뽑기 (로그는 stderr)
|
||||
./export-custom.sh --show-excluded # 규칙에 걸려 빠진 목록 + 걸린 규칙
|
||||
./export-custom.sh # 실제 수집
|
||||
./export-custom.sh --zip # 수집 + zip
|
||||
./export-custom.sh -c 2026-05-01 -m eapim-portal
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\export-custom.ps1 -DryRun > filelist.txt
|
||||
.\export-custom.ps1 -ShowExcluded
|
||||
.\export-custom.ps1 -Zip
|
||||
.\export-custom.ps1 -Cutoff 2026-05-01 -Module eapim-portal,eapim-admin
|
||||
|
||||
# cmd 에서
|
||||
export-custom.bat -DryRun > filelist.txt
|
||||
```
|
||||
|
||||
### 주요 옵션
|
||||
|
||||
| sh | ps1 | 설명 |
|
||||
|---|---|---|
|
||||
| `-c, --cutoff` | `-Cutoff` | 기준 날짜 (기본 `2026-05-01`) |
|
||||
| `-r, --root` | `-Root` | 저장소 루트. 미지정 시 스크립트 위치 기준 자동 탐지 |
|
||||
| `-o, --out` | `-Out` | 출력 디렉터리 (기본 `<root>/build/djb-custom-export`) |
|
||||
| `-m, --module` | `-Module` | 특정 모듈만 |
|
||||
| `--zip` | `-Zip` | zip 생성 |
|
||||
| `--include-untracked` | `-IncludeUntracked` | 미커밋 신규 파일도 포함 |
|
||||
| `--dry-run` | `-DryRun` | 복사 없이 목록만 |
|
||||
| `--show-excluded` | `-ShowExcluded` | 규칙에 걸려 빠진 목록 |
|
||||
|
||||
## `export-filter.rules` 문법
|
||||
|
||||
`.gitignore` 와 비슷하되 더 단순하다.
|
||||
|
||||
- 한 줄에 패턴 하나. `#` 주석, 빈 줄 무시
|
||||
- 기본은 **모두 포함**. 패턴에 걸리면 제외
|
||||
- `!` 로 시작하면 예외(다시 포함)
|
||||
- **뒤에 오는 규칙이 앞 규칙을 덮는다** — 순서가 중요하다
|
||||
- 패턴에 `/` 가 있으면 `<모듈>/<경로>` 전체와 매칭, 없으면 파일명만 매칭
|
||||
- `/` 로 끝나면 그 디렉터리 하위 전체
|
||||
- `*` 는 `/` 도 포함해 매칭한다 (단순 glob, `**` 없음)
|
||||
|
||||
매칭 대상 경로 예:
|
||||
|
||||
```
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/WebhookService.java
|
||||
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookList.jsp
|
||||
```
|
||||
|
||||
규칙 파일은 세 구역으로 나뉜다.
|
||||
|
||||
1. **업그레이드 유입분 제외** — `filelist-no-djb-marker.txt` 를 검토하며 채운다
|
||||
2. **`!*djb*` 예외** — djb 마커가 붙은 파일은 커스텀이므로 되살린다
|
||||
3. **무조건 제외** — 빌드 산출물·서드파티. 2)보다 뒤에 있어 최종 승리한다
|
||||
|
||||
## 신규지만 커스텀이 아닌 파일 걸러내기
|
||||
|
||||
신규 파일이라도 제품 업그레이드로 딸려 온 것일 수 있다. 판별 순서:
|
||||
|
||||
```bash
|
||||
# 1) 전체 목록
|
||||
./export-custom.sh --dry-run > filelist.txt
|
||||
|
||||
# 2) djb / custom 마커가 없는 것 = 검토 대상
|
||||
grep -vi djb filelist.txt | grep -v '/custom/' > filelist-no-djb-marker.txt
|
||||
|
||||
# 3) 검토 후 업그레이드 유입분을 export-filter.rules 의 1) 구역에 추가
|
||||
|
||||
# 4) 반영 확인
|
||||
./export-custom.sh --show-excluded
|
||||
```
|
||||
|
||||
## 주의
|
||||
|
||||
- 컷오프 이전 커밋이 없으면 base 가 **빈 트리**가 되어 저장소 전체가 신규로 잡힌다.
|
||||
`SUMMARY.md` 의 base 컬럼에 `(컷오프 이전 커밋 없음 …)` 으로 표시된다.
|
||||
- 모듈에 컷오프 직전 커밋이 드문드문 있으면 base 가 컷오프보다 훨씬 이전으로 잡힐 수 있다.
|
||||
실행 로그의 `base:` 날짜를 항상 확인할 것.
|
||||
- 추가된 뒤 삭제·이동된 파일은 현재 트리에 없으므로 건너뛰고, 건수를 `SUMMARY.md` 에 남긴다.
|
||||
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM DJB 커스텀 소스 납품본 수집 (Windows cmd 래퍼)
|
||||
REM
|
||||
REM 실제 로직은 export-custom.ps1 에 있다. 이 배치는 PowerShell 을 띄우고
|
||||
REM 전달받은 인자를 그대로 넘기기만 한다.
|
||||
REM
|
||||
REM 사용 예:
|
||||
REM export-custom.bat
|
||||
REM export-custom.bat -Cutoff 2026-05-01 -Zip
|
||||
REM export-custom.bat -DryRun > filelist.txt
|
||||
REM export-custom.bat -ShowExcluded
|
||||
REM export-custom.bat -Module eapim-portal,eapim-admin
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
|
||||
REM 한글 경로/출력이 깨지지 않도록 코드페이지를 UTF-8 로 전환
|
||||
chcp 65001 >nul
|
||||
|
||||
where powershell >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [오류] PowerShell 을 찾을 수 없습니다. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0export-custom.ps1" %*
|
||||
set RC=%ERRORLEVEL%
|
||||
|
||||
endlocal & exit /b %RC%
|
||||
@@ -0,0 +1,278 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
DJB 커스텀 소스 납품본 수집 스크립트 (Windows / PowerShell)
|
||||
|
||||
.DESCRIPTION
|
||||
기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아
|
||||
모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고,
|
||||
MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다.
|
||||
|
||||
.EXAMPLE
|
||||
.\export-custom.ps1
|
||||
.\export-custom.ps1 -Cutoff 2026-05-01 -Zip
|
||||
.\export-custom.ps1 -DryRun
|
||||
.\export-custom.ps1 -Module eapim-portal,eapim-admin
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Cutoff = '2026-05-01',
|
||||
[string] $Root,
|
||||
[string] $Out,
|
||||
[string[]] $Module,
|
||||
[switch] $Zip,
|
||||
[switch] $IncludeUntracked,
|
||||
[switch] $DryRun,
|
||||
[switch] $ShowExcluded
|
||||
)
|
||||
|
||||
if ($ShowExcluded) { $DryRun = $true }
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# git 출력의 한글이 깨지지 않도록 콘솔 인코딩을 UTF-8로 고정한다.
|
||||
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { }
|
||||
$OutputEncoding = [Text.Encoding]::UTF8
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ConfFile = Join-Path $ScriptDir 'modules.conf'
|
||||
$RulesFile = Join-Path $ScriptDir 'export-filter.rules'
|
||||
|
||||
# 저장소 루트: 스크립트는 <root>\eapim-portal\script\djb-custom-export\ 에 있다.
|
||||
if (-not $Root) { $Root = (Resolve-Path (Join-Path $ScriptDir '..\..\..')).Path }
|
||||
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
|
||||
throw "저장소 루트를 찾을 수 없다: $Root"
|
||||
}
|
||||
if (-not $Out) { $Out = Join-Path $Root 'build\djb-custom-export' }
|
||||
if (-not (Test-Path -LiteralPath $ConfFile)) { throw "설정 파일 없음: $ConfFile" }
|
||||
|
||||
# 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다.
|
||||
$EmptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
||||
|
||||
# ---- 설정 로드 ------------------------------------------------------------
|
||||
$Modules = @()
|
||||
$AllowExt = @()
|
||||
|
||||
foreach ($line in (Get-Content -LiteralPath $ConfFile -Encoding UTF8)) {
|
||||
$t = $line.Trim()
|
||||
if ($t -eq '' -or $t.StartsWith('#')) { continue }
|
||||
$idx = $t.IndexOf('|')
|
||||
if ($idx -lt 0) { continue }
|
||||
$key = $t.Substring(0, $idx)
|
||||
$rest = $t.Substring($idx + 1)
|
||||
|
||||
switch ($key) {
|
||||
'MODULE' {
|
||||
$i2 = $rest.IndexOf('|')
|
||||
if ($i2 -lt 0) { continue }
|
||||
$Modules += [pscustomobject]@{
|
||||
Name = $rest.Substring(0, $i2)
|
||||
Paths = $rest.Substring($i2 + 1).Split(' ') | Where-Object { $_ -ne '' }
|
||||
}
|
||||
}
|
||||
'EXT' { $AllowExt = $rest.Split(',') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ -ne '' } }
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 포함/제외 규칙 로드 (.gitignore 유사) --------------------------------
|
||||
$Rules = @()
|
||||
if (Test-Path -LiteralPath $RulesFile) {
|
||||
foreach ($line in (Get-Content -LiteralPath $RulesFile -Encoding UTF8)) {
|
||||
$t = $line.Trim()
|
||||
if ($t -eq '' -or $t.StartsWith('#')) { continue }
|
||||
$Rules += $t
|
||||
}
|
||||
}
|
||||
|
||||
# 마지막으로 매칭된 규칙이 이긴다. 기본 포함, '!' 는 예외(다시 포함).
|
||||
# 반환: @{ Keep = $true/$false; Rule = '<제외시킨 규칙>' }
|
||||
function Test-RuleKeep([string]$RelPath) {
|
||||
$leaf = Split-Path $RelPath -Leaf
|
||||
$keep = $true
|
||||
$matched = ''
|
||||
foreach ($raw in $Rules) {
|
||||
$rule = $raw
|
||||
$neg = $false
|
||||
if ($rule.StartsWith('!')) { $neg = $true; $rule = $rule.Substring(1) }
|
||||
if ($rule.EndsWith('/')) { $rule = $rule + '*' } # 디렉터리 규칙
|
||||
$target = if ($rule.Contains('/')) { $RelPath } else { $leaf }
|
||||
|
||||
if ($target -like $rule) {
|
||||
if ($neg) { $keep = $true; $matched = '' }
|
||||
else { $keep = $false; $matched = $rule }
|
||||
}
|
||||
}
|
||||
return @{ Keep = $keep; Rule = $matched }
|
||||
}
|
||||
|
||||
function Test-AllowedExt([string]$RelPath) {
|
||||
$leaf = Split-Path $RelPath -Leaf
|
||||
if ($leaf -notmatch '\.') { return $false } # 확장자 없는 파일 제외
|
||||
$ext = $leaf.Substring($leaf.LastIndexOf('.') + 1).ToLower()
|
||||
return $AllowExt -contains $ext
|
||||
}
|
||||
|
||||
function Invoke-Git([string]$Dir, [string[]]$GitArgs) {
|
||||
$all = @('-C', $Dir, '-c', 'core.quotepath=false') + $GitArgs
|
||||
$res = & git @all 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { return @() }
|
||||
return @($res)
|
||||
}
|
||||
|
||||
# ---- 준비 -----------------------------------------------------------------
|
||||
$Manifest = Join-Path $Out 'MANIFEST.csv'
|
||||
$Summary = Join-Path $Out 'SUMMARY.md'
|
||||
|
||||
if (-not $DryRun) {
|
||||
if (Test-Path -LiteralPath $Out) { Remove-Item -LiteralPath $Out -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path (Join-Path $Out 'src') -Force | Out-Null
|
||||
# Excel 한글용 UTF-8 BOM
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[IO.File]::WriteAllText($Manifest, "module,path,ext,added_commit,added_date,author`r`n", $utf8Bom)
|
||||
}
|
||||
|
||||
Write-Host "저장소 루트 : $Root"
|
||||
Write-Host "기준 날짜 : $Cutoff (이 날짜 직전 마지막 커밋이 base)"
|
||||
Write-Host "출력 경로 : $Out"
|
||||
if ($DryRun) { Write-Host '모드 : DRY-RUN (복사 안 함)' }
|
||||
Write-Host ''
|
||||
|
||||
$Rows = New-Object System.Collections.Generic.List[string]
|
||||
$SummaryRs = New-Object System.Collections.Generic.List[object]
|
||||
$Total = 0
|
||||
$Missing = 0
|
||||
$Excluded = 0
|
||||
|
||||
# ---- 모듈 순회 ------------------------------------------------------------
|
||||
foreach ($m in $Modules) {
|
||||
if ($Module -and ($Module -notcontains $m.Name)) { continue }
|
||||
|
||||
$modDir = Join-Path $Root $m.Name
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $modDir '.git'))) {
|
||||
Write-Host "[건너뜀] $($m.Name) — git 저장소 아님 ($modDir)"
|
||||
continue
|
||||
}
|
||||
|
||||
# base 커밋 결정
|
||||
$base = (Invoke-Git $modDir @('rev-list', '-1', "--before=$Cutoff", 'HEAD') | Select-Object -First 1)
|
||||
if ([string]::IsNullOrWhiteSpace($base)) {
|
||||
$base = $EmptyTree
|
||||
$baseDesc = '(컷오프 이전 커밋 없음 → 전체를 신규로 간주)'
|
||||
} else {
|
||||
$d = (Invoke-Git $modDir @('log', '-1', '--format=%h %ad %s', '--date=short', $base) | Select-Object -First 1)
|
||||
$baseDesc = if ($d.Length -gt 80) { $d.Substring(0, 80) } else { $d }
|
||||
}
|
||||
|
||||
# 실제 존재하는 소스 경로만 pathspec 으로 사용
|
||||
$spec = @()
|
||||
foreach ($p in $m.Paths) {
|
||||
if (Test-Path -LiteralPath (Join-Path $modDir ($p -replace '/', '\')) -PathType Container) { $spec += $p }
|
||||
}
|
||||
if ($spec.Count -eq 0) {
|
||||
Write-Host "[건너뜀] $($m.Name) — 설정된 소스 경로가 존재하지 않음"
|
||||
continue
|
||||
}
|
||||
|
||||
# 신규(Added) 파일만. -M 으로 rename 은 신규에서 제외한다.
|
||||
$files = @(Invoke-Git $modDir (@('diff', '--name-only', '--diff-filter=A', '-M', $base, 'HEAD', '--') + $spec))
|
||||
|
||||
if ($IncludeUntracked) {
|
||||
$files += @(Invoke-Git $modDir (@('ls-files', '--others', '--exclude-standard', '--') + $spec))
|
||||
}
|
||||
|
||||
$files = $files | Where-Object { $_ -ne '' } | Sort-Object -Unique
|
||||
|
||||
$count = 0
|
||||
$miss = 0
|
||||
foreach ($f in $files) {
|
||||
if (-not (Test-AllowedExt $f)) { continue }
|
||||
|
||||
# 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준
|
||||
$rk = Test-RuleKeep "$($m.Name)/$f"
|
||||
if (-not $rk.Keep) {
|
||||
$Excluded++
|
||||
if ($ShowExcluded) { Write-Output "$($m.Name)/$f`t# $($rk.Rule)" }
|
||||
continue
|
||||
}
|
||||
if ($ShowExcluded) { continue }
|
||||
|
||||
$src = Join-Path $modDir ($f -replace '/', '\')
|
||||
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
|
||||
# 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님
|
||||
Write-Warning " [없음] $($m.Name)/$f"
|
||||
$miss++
|
||||
continue
|
||||
}
|
||||
|
||||
$count++
|
||||
# 목록은 Write-Output(성공 스트림) 으로 — 리다이렉트하면 목록만 파일로 떨어진다.
|
||||
if ($DryRun) { Write-Output "$($m.Name)/$f"; continue }
|
||||
|
||||
$dst = Join-Path (Join-Path $Out 'src') (Join-Path $m.Name ($f -replace '/', '\'))
|
||||
$dstDir = Split-Path -Parent $dst
|
||||
if (-not (Test-Path -LiteralPath $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null }
|
||||
Copy-Item -LiteralPath $src -Destination $dst -Force
|
||||
|
||||
$meta = (Invoke-Git $modDir @('log', '-1', '--diff-filter=A', '--format=%h|%ad|%an', '--date=short', '--', $f) | Select-Object -First 1)
|
||||
$cHash = ''; $cDate = ''; $cAuth = ''
|
||||
if ($meta) {
|
||||
$parts = $meta.Split('|')
|
||||
if ($parts.Count -ge 3) { $cHash = $parts[0]; $cDate = $parts[1]; $cAuth = $parts[2] }
|
||||
}
|
||||
$leaf = Split-Path $f -Leaf
|
||||
$ext = if ($leaf -match '\.') { $leaf.Substring($leaf.LastIndexOf('.') + 1) } else { '' }
|
||||
|
||||
$esc = { param($s) '"' + ($s -replace '"', '""') + '"' }
|
||||
$Rows.Add((@(
|
||||
(& $esc $m.Name), (& $esc $f), (& $esc $ext),
|
||||
(& $esc $cHash), (& $esc $cDate), (& $esc $cAuth)
|
||||
) -join ','))
|
||||
}
|
||||
|
||||
$Total += $count
|
||||
$Missing += $miss
|
||||
$SummaryRs.Add([pscustomobject]@{ Module = $m.Name; Count = $count; Base = $baseDesc })
|
||||
Write-Host ("[수집] {0,-22} {1,5} 개 base: {2}" -f $m.Name, $count, $baseDesc)
|
||||
}
|
||||
|
||||
# ---- 출력 ------------------------------------------------------------------
|
||||
if (-not $DryRun) {
|
||||
if ($Rows.Count -gt 0) {
|
||||
[IO.File]::AppendAllText($Manifest, (($Rows -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
[void]$sb.AppendLine('# DJB 커스텀 소스 납품본')
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine("- 생성 일시: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
|
||||
[void]$sb.AppendLine("- 기준 날짜: ``$Cutoff`` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)")
|
||||
[void]$sb.AppendLine('- 수집 규칙: `git diff --diff-filter=A -M <base> HEAD` / 확장자 allowlist / `export-filter.rules` 필터')
|
||||
[void]$sb.AppendLine("- 대상 확장자: ``$($AllowExt -join ',')``")
|
||||
[void]$sb.AppendLine("- 규칙 파일: ``$(Split-Path $RulesFile -Leaf)`` ($($Rules.Count) 개 규칙, 제외 $Excluded 건)")
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('## 모듈별 수집 결과')
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('| 모듈 | 파일 수 | base 커밋 |')
|
||||
[void]$sb.AppendLine('|---|---:|---|')
|
||||
foreach ($r in $SummaryRs) { [void]$sb.AppendLine("| $($r.Module) | $($r.Count) | $($r.Base) |") }
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine("**합계: $Total 개**")
|
||||
if ($Missing -gt 0) { [void]$sb.AppendLine("> 추가 후 삭제/이동되어 현재 트리에 없는 파일 $Missing 개는 제외됨.") }
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('## 파일 목록')
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('`MANIFEST.csv` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자).')
|
||||
|
||||
[IO.File]::WriteAllText($Summary, $sb.ToString(), (New-Object System.Text.UTF8Encoding($true)))
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "MANIFEST : $Manifest"
|
||||
Write-Host "SUMMARY : $Summary"
|
||||
|
||||
if ($Zip) {
|
||||
$zipName = "djb-custom-export-$(Get-Date -Format 'yyyyMMdd').zip"
|
||||
$zipPath = Join-Path (Split-Path -Parent $Out) $zipName
|
||||
if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
|
||||
Compress-Archive -Path (Join-Path $Out '*') -DestinationPath $zipPath
|
||||
Write-Host "ZIP : $zipPath"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# DJB 커스텀 소스 납품본 수집 스크립트 (macOS / Linux)
|
||||
#
|
||||
# 기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아
|
||||
# 모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고,
|
||||
# MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다.
|
||||
#
|
||||
# 사용법:
|
||||
# ./export-custom.sh # 기본값으로 실행
|
||||
# ./export-custom.sh -c 2026-05-01 --zip
|
||||
# ./export-custom.sh --dry-run
|
||||
# ./export-custom.sh -m eapim-portal -m eapim-admin
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONF_FILE="$SCRIPT_DIR/modules.conf"
|
||||
RULES_FILE="$SCRIPT_DIR/export-filter.rules"
|
||||
|
||||
# ---- 기본값 ---------------------------------------------------------------
|
||||
CUTOFF="2026-05-01"
|
||||
REPO_ROOT=""
|
||||
OUT_DIR=""
|
||||
MAKE_ZIP=0
|
||||
DRY_RUN=0
|
||||
INCLUDE_UNTRACKED=0
|
||||
ONLY_MODULES=""
|
||||
SHOW_EXCLUDED=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
DJB 커스텀 소스 납품본 수집
|
||||
|
||||
옵션:
|
||||
-c, --cutoff <YYYY-MM-DD> 기준 날짜. 이 날짜 직전 마지막 커밋을 base로 삼는다.
|
||||
(기본: 2026-05-01)
|
||||
-r, --root <DIR> 저장소 루트(djb-eapim). 미지정 시 스크립트 위치에서 자동 탐지.
|
||||
-o, --out <DIR> 출력 디렉터리. (기본: <root>/build/djb-custom-export)
|
||||
-m, --module <NAME> 특정 모듈만 수집. 여러 번 지정 가능.
|
||||
--zip 수집 후 zip 아카이브 생성.
|
||||
--include-untracked git에 아직 커밋되지 않은 신규 파일도 포함.
|
||||
--dry-run 복사하지 않고 대상 목록만 stdout 으로 출력(로그는 stderr).
|
||||
--show-excluded --dry-run 과 함께. 규칙에 걸려 제외된 목록을 대신 출력.
|
||||
-h, --help 도움말.
|
||||
|
||||
포함/제외 규칙은 export-filter.rules (.gitignore 유사 문법) 에서 관리한다.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-c|--cutoff) CUTOFF="$2"; shift 2 ;;
|
||||
-r|--root) REPO_ROOT="$2"; shift 2 ;;
|
||||
-o|--out) OUT_DIR="$2"; shift 2 ;;
|
||||
-m|--module) ONLY_MODULES="$ONLY_MODULES $2"; shift 2 ;;
|
||||
--zip) MAKE_ZIP=1; shift ;;
|
||||
--include-untracked) INCLUDE_UNTRACKED=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--show-excluded) SHOW_EXCLUDED=1; DRY_RUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "알 수 없는 옵션: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- 저장소 루트 탐지 -----------------------------------------------------
|
||||
# 스크립트는 <root>/eapim-portal/script/djb-custom-export/ 에 있다.
|
||||
if [ -z "$REPO_ROOT" ]; then
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
fi
|
||||
if [ ! -d "$REPO_ROOT" ]; then
|
||||
echo "저장소 루트를 찾을 수 없다: $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -z "$OUT_DIR" ] && OUT_DIR="$REPO_ROOT/build/djb-custom-export"
|
||||
|
||||
if [ ! -f "$CONF_FILE" ]; then
|
||||
echo "설정 파일 없음: $CONF_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 설정 로드 ------------------------------------------------------------
|
||||
ALLOW_EXT=""
|
||||
EXCLUDE_PAT=""
|
||||
MODULE_LINES=""
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|'#'*) continue ;;
|
||||
esac
|
||||
key="${line%%|*}"
|
||||
rest="${line#*|}"
|
||||
case "$key" in
|
||||
MODULE) MODULE_LINES="$MODULE_LINES
|
||||
$rest" ;;
|
||||
EXT) ALLOW_EXT="$rest" ;;
|
||||
EXCLUDE) EXCLUDE_PAT="$rest" ;;
|
||||
esac
|
||||
done < "$CONF_FILE"
|
||||
|
||||
# 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다.
|
||||
EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
||||
|
||||
is_allowed_ext() {
|
||||
_f="$1"
|
||||
_base="${_f##*/}"
|
||||
case "$_base" in
|
||||
*.*) _ext="${_base##*.}" ;;
|
||||
*) return 1 ;; # 확장자 없는 파일 제외
|
||||
esac
|
||||
# 소문자 변환 (bash 3.2 호환)
|
||||
_ext="$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')"
|
||||
case ",$ALLOW_EXT," in
|
||||
*",$_ext,"*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- 포함/제외 규칙 (.gitignore 유사) --------------------------------------
|
||||
RULES=()
|
||||
if [ -f "$RULES_FILE" ]; then
|
||||
while IFS= read -r r || [ -n "$r" ]; do
|
||||
# 앞뒤 공백 제거
|
||||
r="${r#"${r%%[![:space:]]*}"}"
|
||||
r="${r%"${r##*[![:space:]]}"}"
|
||||
case "$r" in
|
||||
''|'#'*) continue ;;
|
||||
esac
|
||||
RULES[${#RULES[@]}]="$r"
|
||||
done < "$RULES_FILE"
|
||||
fi
|
||||
|
||||
# 경로 하나에 규칙을 순서대로 적용한다. 마지막으로 매칭된 규칙이 이긴다.
|
||||
# 기본은 포함(0), '!' 규칙은 다시 포함, 일반 규칙은 제외(1).
|
||||
# 반환: 0 = 포함, 1 = 제외. 제외시킨 규칙은 MATCHED_RULE 에 담긴다.
|
||||
MATCHED_RULE=""
|
||||
rule_keep() {
|
||||
_path="$1"
|
||||
_leaf="${_path##*/}"
|
||||
_keep=0
|
||||
MATCHED_RULE=""
|
||||
_i=0
|
||||
while [ $_i -lt ${#RULES[@]} ]; do
|
||||
_rule="${RULES[$_i]}"
|
||||
_i=$((_i + 1))
|
||||
|
||||
_neg=0
|
||||
case "$_rule" in
|
||||
'!'*) _neg=1; _rule="${_rule#!}" ;;
|
||||
esac
|
||||
# 디렉터리 규칙: 하위 전체
|
||||
case "$_rule" in
|
||||
*/) _rule="${_rule}*" ;;
|
||||
esac
|
||||
# '/' 가 있으면 전체 경로, 없으면 파일명과 매칭
|
||||
case "$_rule" in
|
||||
*/*) _target="$_path" ;;
|
||||
*) _target="$_leaf" ;;
|
||||
esac
|
||||
|
||||
# shellcheck disable=SC2254 # 의도적으로 unquoted — glob 패턴으로 쓴다
|
||||
case "$_target" in
|
||||
$_rule)
|
||||
if [ $_neg -eq 1 ]; then
|
||||
_keep=0; MATCHED_RULE=""
|
||||
else
|
||||
_keep=1; MATCHED_RULE="$_rule"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return $_keep
|
||||
}
|
||||
|
||||
csv_escape() {
|
||||
printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')"
|
||||
}
|
||||
|
||||
# ---- 준비 -----------------------------------------------------------------
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$OUT_DIR/src"
|
||||
fi
|
||||
|
||||
MANIFEST="$OUT_DIR/MANIFEST.csv"
|
||||
SUMMARY="$OUT_DIR/SUMMARY.md"
|
||||
TMP_SUMMARY="$(mktemp)"
|
||||
trap 'rm -f "$TMP_SUMMARY"' EXIT
|
||||
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
printf '\357\273\277' > "$MANIFEST" # Excel 한글용 UTF-8 BOM
|
||||
echo 'module,path,ext,added_commit,added_date,author' >> "$MANIFEST"
|
||||
fi
|
||||
|
||||
# dry-run(목록 뽑기) 모드에서는 파일 목록만 stdout으로 내보내고
|
||||
# 진행 로그는 전부 stderr로 보낸다. 그래야 파이프/리다이렉트로 목록을 바로 가공할 수 있다.
|
||||
if [ "$DRY_RUN" -eq 1 ]; then LOG=2; else LOG=1; fi
|
||||
|
||||
{
|
||||
echo "저장소 루트 : $REPO_ROOT"
|
||||
echo "기준 날짜 : $CUTOFF (이 날짜 직전 마지막 커밋이 base)"
|
||||
echo "출력 경로 : $OUT_DIR"
|
||||
[ "$DRY_RUN" -eq 1 ] && echo "모드 : DRY-RUN (목록만 출력, 복사 안 함)"
|
||||
echo
|
||||
} >&$LOG
|
||||
|
||||
TOTAL=0
|
||||
MISSING=0
|
||||
EXCLUDED=0
|
||||
|
||||
# ---- 모듈 순회 ------------------------------------------------------------
|
||||
# 파이프 대신 process substitution 을 써서 집계 변수가 서브셸에 갇히지 않게 한다.
|
||||
while IFS= read -r mline; do
|
||||
[ -z "$mline" ] && continue
|
||||
MOD="${mline%%|*}"
|
||||
PATHS="${mline#*|}"
|
||||
|
||||
if [ -n "$ONLY_MODULES" ]; then
|
||||
case " $ONLY_MODULES " in
|
||||
*" $MOD "*) : ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
MOD_DIR="$REPO_ROOT/$MOD"
|
||||
if [ ! -d "$MOD_DIR/.git" ]; then
|
||||
echo "[건너뜀] $MOD — git 저장소 아님 ($MOD_DIR)" >&$LOG
|
||||
continue
|
||||
fi
|
||||
|
||||
# base 커밋 결정
|
||||
BASE="$(git -C "$MOD_DIR" -c core.quotepath=false rev-list -1 --before="$CUTOFF" HEAD 2>/dev/null || true)"
|
||||
if [ -z "$BASE" ]; then
|
||||
BASE="$EMPTY_TREE"
|
||||
BASE_DESC="(컷오프 이전 커밋 없음 → 전체를 신규로 간주)"
|
||||
else
|
||||
BASE_DESC="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --format='%h %ad %s' --date=short "$BASE" | cut -c1-80)"
|
||||
fi
|
||||
|
||||
# 실제 존재하는 소스 경로만 pathspec으로 사용
|
||||
SPEC=""
|
||||
for p in $PATHS; do
|
||||
[ -d "$MOD_DIR/$p" ] && SPEC="$SPEC $p"
|
||||
done
|
||||
if [ -z "$SPEC" ]; then
|
||||
echo "[건너뜀] $MOD — 설정된 소스 경로가 존재하지 않음" >&$LOG
|
||||
continue
|
||||
fi
|
||||
|
||||
# 신규(Added) 파일만. -M 으로 rename은 신규에서 제외한다.
|
||||
FILES="$(git -C "$MOD_DIR" -c core.quotepath=false diff --name-only --diff-filter=A -M "$BASE" HEAD -- $SPEC 2>/dev/null || true)"
|
||||
|
||||
if [ "$INCLUDE_UNTRACKED" -eq 1 ]; then
|
||||
UNTRACKED="$(git -C "$MOD_DIR" -c core.quotepath=false ls-files --others --exclude-standard -- $SPEC 2>/dev/null || true)"
|
||||
FILES="$FILES
|
||||
$UNTRACKED"
|
||||
fi
|
||||
|
||||
COUNT=0
|
||||
MISS=0
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
is_allowed_ext "$f" || continue
|
||||
|
||||
# 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준
|
||||
if ! rule_keep "$MOD/$f"; then
|
||||
EXCLUDED=$((EXCLUDED + 1))
|
||||
[ "$SHOW_EXCLUDED" -eq 1 ] && printf '%s\t# %s\n' "$MOD/$f" "$MATCHED_RULE"
|
||||
continue
|
||||
fi
|
||||
[ "$SHOW_EXCLUDED" -eq 1 ] && continue
|
||||
|
||||
SRC="$MOD_DIR/$f"
|
||||
if [ ! -f "$SRC" ]; then
|
||||
# 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님
|
||||
echo " [없음] $MOD/$f" >&2
|
||||
MISS=$((MISS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
COUNT=$((COUNT + 1))
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "$MOD/$f" # 목록은 stdout으로 (가공하기 쉽게 접두 공백 없음)
|
||||
continue
|
||||
fi
|
||||
|
||||
DST="$OUT_DIR/src/$MOD/$f"
|
||||
mkdir -p "$(dirname "$DST")"
|
||||
cp -p "$SRC" "$DST"
|
||||
|
||||
META="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --diff-filter=A --format='%h|%ad|%an' --date=short -- "$f" 2>/dev/null || true)"
|
||||
C_HASH="${META%%|*}"; _r="${META#*|}"
|
||||
C_DATE="${_r%%|*}"
|
||||
C_AUTH="${_r#*|}"
|
||||
BASE_F="${f##*/}"
|
||||
case "$BASE_F" in *.*) EXT="${BASE_F##*.}" ;; *) EXT="" ;; esac
|
||||
|
||||
{
|
||||
csv_escape "$MOD"; printf ','
|
||||
csv_escape "$f"; printf ','
|
||||
csv_escape "$EXT"; printf ','
|
||||
csv_escape "$C_HASH"; printf ','
|
||||
csv_escape "$C_DATE"; printf ','
|
||||
csv_escape "$C_AUTH"; printf '\n'
|
||||
} >> "$MANIFEST"
|
||||
done < <(printf '%s\n' "$FILES" | sort -u)
|
||||
|
||||
TOTAL=$((TOTAL + COUNT))
|
||||
MISSING=$((MISSING + MISS))
|
||||
printf '%s|%s|%s\n' "$MOD" "$COUNT" "$BASE_DESC" >> "$TMP_SUMMARY"
|
||||
printf '[수집] %-22s %5s 개 base: %s\n' "$MOD" "$COUNT" "$BASE_DESC" >&$LOG
|
||||
done < <(printf '%s\n' "$MODULE_LINES")
|
||||
|
||||
# ---- 요약 ------------------------------------------------------------------
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
{
|
||||
echo "# DJB 커스텀 소스 납품본"
|
||||
echo
|
||||
echo "- 생성 일시: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "- 기준 날짜: \`$CUTOFF\` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)"
|
||||
echo "- 수집 규칙: \`git diff --diff-filter=A -M <base> HEAD\` / 확장자 allowlist / \`export-filter.rules\` 필터"
|
||||
echo "- 대상 확장자: \`$ALLOW_EXT\`"
|
||||
echo "- 규칙 파일: \`$(basename "$RULES_FILE")\` (${#RULES[@]} 개 규칙, 제외 ${EXCLUDED} 건)"
|
||||
echo
|
||||
echo "## 모듈별 수집 결과"
|
||||
echo
|
||||
echo "| 모듈 | 파일 수 | base 커밋 |"
|
||||
echo "|---|---:|---|"
|
||||
while IFS='|' read -r m n d; do
|
||||
[ -z "$m" ] && continue
|
||||
echo "| $m | $n | $d |"
|
||||
done < "$TMP_SUMMARY"
|
||||
echo
|
||||
echo "**합계: ${TOTAL} 개**"
|
||||
[ "$MISSING" -gt 0 ] && echo "> 추가 후 삭제/이동되어 현재 트리에 없는 파일 ${MISSING} 개는 제외됨."
|
||||
echo
|
||||
echo "## 파일 목록"
|
||||
echo
|
||||
echo "\`MANIFEST.csv\` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자)."
|
||||
} > "$SUMMARY"
|
||||
|
||||
echo
|
||||
echo "MANIFEST : $MANIFEST"
|
||||
echo "SUMMARY : $SUMMARY"
|
||||
|
||||
if [ "$MAKE_ZIP" -eq 1 ]; then
|
||||
ZIP_NAME="djb-custom-export-$(date '+%Y%m%d').zip"
|
||||
ZIP_PATH="$(dirname "$OUT_DIR")/$ZIP_NAME"
|
||||
rm -f "$ZIP_PATH"
|
||||
( cd "$OUT_DIR" && zip -qr "$ZIP_PATH" . )
|
||||
echo "ZIP : $ZIP_PATH"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,74 @@
|
||||
# DJB 커스텀 소스 납품 - 포함/제외 규칙
|
||||
#
|
||||
# .gitignore 와 비슷하게 동작한다.
|
||||
# - 한 줄에 패턴 하나. '#' 주석, 빈 줄 무시.
|
||||
# - 기본은 "모두 포함". 패턴에 걸리면 제외된다.
|
||||
# - '!' 로 시작하면 예외(다시 포함).
|
||||
# - 뒤에 오는 규칙이 앞 규칙을 덮는다 (마지막 매칭 승리). → 순서가 중요하다.
|
||||
# - 패턴에 '/' 가 있으면 <모듈>/<경로> 전체와 glob 매칭,
|
||||
# 없으면 파일명(basename)과만 매칭한다.
|
||||
# - '/' 로 끝나면 그 디렉터리 하위 전체를 뜻한다.
|
||||
# - '*' 는 '/' 도 포함해 매칭한다(단순 glob).
|
||||
#
|
||||
# 매칭 대상 경로 예:
|
||||
# eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/WebhookService.java
|
||||
# eapim-admin/WebContent/jsp/onl/apim/webhook/webhookList.jsp
|
||||
#
|
||||
# 확인: ./export-custom.sh --dry-run (남는 목록)
|
||||
# ./export-custom.sh --dry-run --show-excluded (규칙에 걸려 빠진 목록)
|
||||
|
||||
# ===========================================================================
|
||||
# 1) 제품 업그레이드로 유입된 신규 파일 — 커스텀 아니므로 제외
|
||||
# filelist-no-djb-marker.txt(djb/custom 마커 없는 신규 파일)를 검토하며 채운다.
|
||||
# ===========================================================================
|
||||
# 예시)
|
||||
# eapim-online/src/main/java/com/eactive/eai/agent/inflow/
|
||||
# eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/
|
||||
|
||||
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/apis/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/app/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/
|
||||
eapim-portal/src/main/resources/templates/views/fragment/popup/
|
||||
eapim-portal/src/test/
|
||||
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2) 위 제외에 걸렸더라도 되살릴 예외
|
||||
# djb 마커가 붙은 파일은 어떤 경우에도 커스텀이다.
|
||||
# ※ 아래 3)의 빌드 산출물 제외보다 반드시 앞에 와야 한다.
|
||||
# ===========================================================================
|
||||
!*djb*
|
||||
!*DJB*
|
||||
!*Djb*
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3) 무조건 제외 (최종 승리) — 소스가 아니거나 서드파티
|
||||
# ===========================================================================
|
||||
*/build/
|
||||
*/out/
|
||||
*/bin/
|
||||
*/.gradle/
|
||||
*/.metadata/
|
||||
*/node_modules/
|
||||
*/generated/
|
||||
*/design-backup/
|
||||
*/WebContent/plugins/
|
||||
|
||||
# SafeDB(damo) 벤더 API stub — 컴파일용 껍데기라 납품 대상 아님
|
||||
damo-manager/src/stub/
|
||||
|
||||
# 서드파티 폴더 안에 섞여 있는 우리 커스텀 파일은 되살린다
|
||||
# (예: WebContent/plugins/swaggerUI/djb-swagger-i18n.js)
|
||||
!*/WebContent/plugins/*djb*
|
||||
@@ -0,0 +1,157 @@
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDay.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDayId.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHour.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHourId.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonth.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonthId.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwApiStatus.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwAuthClient.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwSvcInfo.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsDayRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsHourRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsMonthRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwApiStatusRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwAuthClientRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwSvcInfoRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/app/service/AdminGatewayClient.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorChannel.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorInfoResponse.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorSendResponse.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorVerifyResponse.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpAuthInterceptor.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpPasswordController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpProtectedPaths.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorCleanupScheduler.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorContext.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/community/notice/dto/IncidentAffectedApiDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/community/partnership/dto/PartnershipApplicationSummaryDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/constants/LoginFailureReason.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/constants/LoginType.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/controller/DuplicateLoginController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/service/DuplicateLoginService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/ReadinessController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/entity/UserSession.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/filter/SessionValidationFilter.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/repository/UserSessionRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsPeriodDto.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/repository/entity/JobInfo.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/repository/JobInfoRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/user/service/UserRoleHistoryService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolver.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/security/ClientGuardService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/security/LoginLockPolicy.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/PasswordChangeEnforcementInterceptor.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/PasswordEnforcementPolicy.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/PortalPropertyDuplicateChecker.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/StartupInfoPrinter.java
|
||||
eapim-portal/src/main/resources/menu.yml
|
||||
eapim-portal/src/main/resources/roles.yml
|
||||
eapim-portal/src/main/resources/static/js/api-selector.js
|
||||
eapim-portal/src/main/resources/static/js/password-policy.js
|
||||
eapim-portal/src/main/resources/static/js/popup/two-factor-auth.js
|
||||
eapim-portal/src/main/resources/static/sass/components/_board-common.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_password-policy.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_session-timer.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_toast.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_two-factor.scss
|
||||
eapim-portal/src/main/resources/static/sass/pages/_api-status.scss
|
||||
eapim-portal/src/main/resources/static/sass/pages/_webhook.scss
|
||||
eapim-portal/src/main/resources/templates/views/apps/auth/stepupPassword.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/auth/twoFactorChallenge.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/community/mainFaqDetail.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/register/signupVerificationEmail.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/service/oauth2-guide.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/service/webhook-dev-guide.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookEmpty.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookList.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep2.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep3.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep2.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep3.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/api_selector.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/popup/cancelInvitationPopup.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/popup/terminateRequestPopup.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/popup/twoFactorAuthPopup.html
|
||||
eapim-portal/src/test/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolverTest.java
|
||||
eapim-portal/src/test/java/com/eactive/apim/portal/common/util/PhoneNumberUtilTest.java
|
||||
eapim-portal/src/test/java/com/eactive/apim/portal/common/util/StringMaskingUtilTest.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapServiceImpl.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/ui/SitemapNode.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestEmsRepository.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestUISearch.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/portalinquiry/PortalInquiryCommentRepository.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/portalinquiry/PortalInquiryCommentService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigDataService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigDataServiceImpl.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigRepository.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/authserver/ClientBlockApiController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/masking/MessagePatternMaskingUtils.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryClosingService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryCommentUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryCommentUIMapper.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUIMapper.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentAffectedApiUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentTimelineUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigManController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigManService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigUiMapper.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/inflow/inflow/InflowClientControlManController.java
|
||||
eapim-admin/src/main/resources/apistatus-draft.yml
|
||||
eapim-admin/src/main/resources/logback-rinjae_ma.xml
|
||||
eapim-admin/src/test/java/com/eactive/eai/rms/onl/apim/masking/MaskingUtilsTest.java
|
||||
eapim-admin/src/test/java/com/eactive/eai/rms/onl/apim/masking/MessagePatternMaskingUtilsTest.java
|
||||
eapim-admin/WebContent/damo.jsp
|
||||
eapim-admin/WebContent/jsp/common/acl/sitemap/sitemapMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowClientControlMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowClientControlManDetail.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/rule/transform2/transform2ManApiPopup.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/security/cryptoModuleMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/security/cryptoModuleManDetail.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/messagerequest/messageRequestMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/messagerequest/messageRequestManDetail.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookSendLogMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookSendLogManDetail.jsp
|
||||
eapim-admin/WebContent/jsp/onl/kjb/statistics/apiUseStatsMan.jsp
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/apprequest/entity/GwAction.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/IpAddressMatcher.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/PhoneNumberUtil.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/PersonalDataEncryptConverter.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/UuidV7Generator.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalMenuItem.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalMenuPlacement.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRole.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRoleAuthority.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRoleAuthorityId.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalMenuItemRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalMenuPlacementRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalRoleAuthorityRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalRoleRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/service/PortalMenuDataService.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/entity/UserRoleHistory.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/repository/UserRoleHistoryRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/InquiryComment.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/VisibilityScope.java
|
||||
elink-portal-common/src/test/java/com/eactive/apim/portal/common/util/IpAddressMatcherTest.java
|
||||
@@ -0,0 +1,173 @@
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/custom/config/DjbPasswordEncoder.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/controller/ApiStatusController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ActiveIncidentDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/AffectedApiDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiCurrentStatusDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiOptionDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/DailyStatDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/IssueDateEntryDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/MaintenanceCardDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/MyApiStatusDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/PastIssueCardDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/TimelineEntryDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusAssembler.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusSupport.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/MyApiStatusQueryService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/controller/InquiryCommentController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/dto/InquiryCommentCreateRequest.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/dto/InquiryCommentDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/UserInfoRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacade.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacadeImpl.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/constant/DjbInquiryStatus.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/exception/InquiryClosedException.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/exception/InquiryCommentNotOwnedException.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/support/InquiryCommentPermissionChecker.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/footer/RelatedSite.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/footer/RelatedSiteService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuAccessInterceptor.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuInternalController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuModelAdvice.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuNode.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuSeeder.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuView.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/PortalMenuYmlProperties.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/PortalRolesProperties.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/EmployeeIdPolicy.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingMessageWriter.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifier.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifyProperties.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/config/SwingAsyncConfig.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/repository/SwingStaffRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/advice/DjbTestbedExceptionHandler.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedAuthController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialOptionDto.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialSecretDto.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbTestbedContextDto.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbGatewayMode.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/exception/DjbUnsupportedAuthTypeException.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedAuthService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookCreatedResult.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookEventTypeDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/exception/WebhookAlreadyExistsException.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/exception/WebhookNotFoundException.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/mapper/WebhookMapper.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestApiRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestEventRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequest.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestApi.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestApiId.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestEvent.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestEventId.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookEventTypeProvider.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookSecretGenerator.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookService.java
|
||||
eapim-portal/src/main/resources/menu.yml
|
||||
eapim-portal/src/main/resources/roles.yml
|
||||
eapim-portal/src/main/resources/static/favicon.png
|
||||
eapim-portal/src/main/resources/static/img/avatar1.svg
|
||||
eapim-portal/src/main/resources/static/img/avatar2.svg
|
||||
eapim-portal/src/main/resources/static/img/favicon/apple-touch-icon.png
|
||||
eapim-portal/src/main/resources/static/img/favicon/favicon-16x16.png
|
||||
eapim-portal/src/main/resources/static/img/favicon/favicon-180x180.png
|
||||
eapim-portal/src/main/resources/static/img/favicon/favicon-192x192.png
|
||||
eapim-portal/src/main/resources/static/img/favicon/favicon-32x32.png
|
||||
eapim-portal/src/main/resources/static/img/favicon/favicon-48x48.png
|
||||
eapim-portal/src/main/resources/static/img/favicon/favicon-512x512.png
|
||||
eapim-portal/src/main/resources/static/img/favicon/favicon.png
|
||||
eapim-portal/src/main/resources/static/img/icon/icon_check_green.png
|
||||
eapim-portal/src/main/resources/static/img/icon/img_icon.png
|
||||
eapim-portal/src/main/resources/static/img/keyimage/api_img.png
|
||||
eapim-portal/src/main/resources/static/img/keyimage/api_img.svg
|
||||
eapim-portal/src/main/resources/static/img/keyimage/faq_img.png
|
||||
eapim-portal/src/main/resources/static/img/keyimage/faq_img.svg
|
||||
eapim-portal/src/main/resources/static/img/keyimage/feedback_img.svg
|
||||
eapim-portal/src/main/resources/static/img/keyimage/notice_img.png
|
||||
eapim-portal/src/main/resources/static/img/keyimage/notice_img.svg
|
||||
eapim-portal/src/main/resources/static/img/keyimage/q&a_img.png
|
||||
eapim-portal/src/main/resources/static/img/keyimage/q&a_img.svg
|
||||
eapim-portal/src/main/resources/static/img/keyimage/sigdnUp_img.svg
|
||||
eapim-portal/src/main/resources/static/img/keyimage/signUp_img.png
|
||||
eapim-portal/src/main/resources/static/img/logo/logo-djb.png
|
||||
eapim-portal/src/main/resources/static/img/logo/logo-jjb.png
|
||||
eapim-portal/src/main/resources/static/img/logo/logo-jjb_white.png
|
||||
eapim-portal/src/main/resources/static/js/api-selector.js
|
||||
eapim-portal/src/main/resources/static/js/djb/api-status-issues.js
|
||||
eapim-portal/src/main/resources/static/js/djb/api-status.js
|
||||
eapim-portal/src/main/resources/static/js/djb/client-guard.js
|
||||
eapim-portal/src/main/resources/static/js/djb/inquiry-comments.js
|
||||
eapim-portal/src/main/resources/static/js/djb/inquiry-view.js
|
||||
eapim-portal/src/main/resources/static/js/djb/toast.js
|
||||
eapim-portal/src/main/resources/static/js/password-policy.js
|
||||
eapim-portal/src/main/resources/static/js/popup/two-factor-auth.js
|
||||
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-i18n.js
|
||||
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js
|
||||
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippet-panel.js
|
||||
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippets.js
|
||||
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-testbed.css
|
||||
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-validator.js
|
||||
eapim-portal/src/main/resources/static/sass/base/_djb-font.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_board-common.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_djb-inquiry-comments.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_password-policy.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_session-timer.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_toast.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_two-factor.scss
|
||||
eapim-portal/src/main/resources/static/sass/pages/_api-status.scss
|
||||
eapim-portal/src/main/resources/static/sass/pages/_webhook.scss
|
||||
eapim-portal/src/main/resources/templates/views/apps/auth/stepupPassword.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/auth/twoFactorChallenge.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/community/djb/fragments-inquiry-comments.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/community/mainFaqDetail.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/register/signupVerificationEmail.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/service/oauth2-guide.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/service/webhook-dev-guide.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookEmpty.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookList.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep2.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep3.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep2.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep3.html
|
||||
eapim-portal/src/main/resources/templates/views/djb/apistatus/index.html
|
||||
eapim-portal/src/main/resources/templates/views/djb/apistatus/issues.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/api_selector.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/djbank/footer.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/djbank/header_container.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/djbank/service_sidebar.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/djbank/terms_agreements.html
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/apprequest/entity/GwAction.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncident.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentApi.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentApiId.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentTimeline.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/IncidentKind.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/IncidentState.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentApiRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentTimelineRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/entity/UserRoleHistory.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/repository/UserRoleHistoryRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/InquiryComment.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/VisibilityScope.java
|
||||
damo-manager/src/main/java/com/eactive/ext/djb/DamoCli.java
|
||||
damo-manager/src/main/java/com/eactive/ext/djb/DamoManager.java
|
||||
@@ -0,0 +1,26 @@
|
||||
# DJB 커스텀 소스 납품 수집 설정
|
||||
#
|
||||
# 형식:
|
||||
# MODULE|<모듈 디렉터리>|<소스 경로들, 공백 구분>
|
||||
# EXT|<수집 대상 확장자, 콤마 구분>
|
||||
#
|
||||
# - 모듈 디렉터리는 저장소 루트(djb-eapim) 기준 상대 경로.
|
||||
# - 소스 경로가 실제로 없으면 조용히 건너뛴다.
|
||||
# - '#'으로 시작하는 줄과 빈 줄은 무시된다.
|
||||
# - 파일 단위 포함/제외는 export-filter.rules 에서 관리한다.
|
||||
|
||||
MODULE|eapim-portal|src/main
|
||||
#MODULE|eapim-admin|src WebContent
|
||||
MODULE|elink-portal-common|src/main
|
||||
MODULE|damo-manager|src
|
||||
|
||||
# eapim-online 은 납품 범위에서 제외한다.
|
||||
# Gateway 제품(elink-online) 본체라 신규 파일 중 무엇이 DJB 커스텀이고
|
||||
# 무엇이 제품 업그레이드분인지 포털 쪽에서 판정할 수 없다.
|
||||
# 규칙 파일이 아니라 여기서 빼는 이유: export-filter.rules 의 '!*djb*' 예외가
|
||||
# 뒤에 있어 DJBEncrypt.java 같은 파일이 되살아나기 때문.
|
||||
#MODULE|eapim-online|src elink-online-common/src elink-online-core/src elink-online-core-jpa/src elink-online-emsclient/src elink-online-transformer/src
|
||||
|
||||
# 수집 대상 확장자 (확장자 없는 파일은 제외된다)
|
||||
# EXT|java,xml,yml,yaml,properties,sql,html,jsp,jspf,tag,js,ts,css,scss,json,txt,md
|
||||
EXT|java,xml,yml,yaml,properties,html,jsp,jspf,tag,js,ts,css,scss,jpg,jpeg,png,gif,svg,ico
|
||||
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
|
||||
)
|
||||
;
|
||||
|
||||
create table DVPOWN.PT_TOKEN
|
||||
create table PT_TOKEN
|
||||
(
|
||||
(
|
||||
TOKEN VARCHAR2(255) not null
|
||||
primary key,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- =============================================================================
|
||||
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
|
||||
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||
-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE PTL_USER_SESSION
|
||||
(
|
||||
SESSION_ID VARCHAR2(128) NOT NULL,
|
||||
USER_ID VARCHAR2(36) NOT NULL,
|
||||
LOGIN_ID VARCHAR2(200) NOT NULL,
|
||||
LOGIN_TIME TIMESTAMP(6) NOT NULL,
|
||||
LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
|
||||
IP_ADDRESS VARCHAR2(45),
|
||||
USER_AGENT VARCHAR2(500),
|
||||
FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
|
||||
CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
|
||||
);
|
||||
|
||||
-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
|
||||
CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
|
||||
|
||||
COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
|
||||
|
||||
-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
|
||||
-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
|
||||
-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
|
||||
-- COMMIT;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- =============================================================================
|
||||
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
|
||||
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
|
||||
--
|
||||
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
|
||||
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
|
||||
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
|
||||
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
|
||||
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
|
||||
--
|
||||
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
|
||||
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
|
||||
|
||||
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
|
||||
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
|
||||
|
||||
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* API Gateway 일별 처리 통계 (AGWAPP.API_STATS_DAY) — 읽기 전용.
|
||||
*
|
||||
* 월별 조회 시 "현재 월"은 아직 API_STATS_MONTH 에 집계되지 않으므로(월 집계 잡은 익월 1일 실행)
|
||||
* 현재 월분은 이 DAY 테이블에서 조회한다.
|
||||
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "API_STATS_DAY")
|
||||
@IdClass(ApiStatsDayId.class)
|
||||
public class ApiStatsDay implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "STAT_TIME", nullable = false)
|
||||
private LocalDate statTime;
|
||||
|
||||
@Id
|
||||
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||
private String apiName;
|
||||
|
||||
@Id
|
||||
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||
private String gwInstanceId;
|
||||
|
||||
@Id
|
||||
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||
private String bizDivCode = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||
private String clientId = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||
private String inboundAdapter = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||
private String outboundAdapter = "NONE";
|
||||
|
||||
@Column(name = "TOTAL_CNT", nullable = false)
|
||||
private Long totalCnt = 0L;
|
||||
|
||||
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||
private Long successCnt = 0L;
|
||||
|
||||
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||
private Long timeoutCnt = 0L;
|
||||
|
||||
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||
private Long systemErrCnt = 0L;
|
||||
|
||||
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||
private Long bizErrCnt = 0L;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 통계 일별 복합키
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatsDayId implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private LocalDate statTime;
|
||||
private String apiName;
|
||||
private String gwInstanceId;
|
||||
private String bizDivCode;
|
||||
private String clientId;
|
||||
private String inboundAdapter;
|
||||
private String outboundAdapter;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* API Gateway 시간별 처리 통계 (AGWAPP.API_STATS_HOUR).
|
||||
*
|
||||
* 스키마명은 소스에 하드코딩하지 않는다 — gateway datasource 의 hibernate.default_schema(AGWAPP)로 해석된다.
|
||||
* 포털은 읽기 전용으로만 사용한다(집계 주체는 eapim-admin 스케줄러). 사용 컬럼(카운트)만 매핑한다.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "API_STATS_HOUR")
|
||||
@IdClass(ApiStatsHourId.class)
|
||||
public class ApiStatsHour implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "STAT_TIME", nullable = false)
|
||||
private LocalDateTime statTime;
|
||||
|
||||
@Id
|
||||
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||
private String apiName;
|
||||
|
||||
@Id
|
||||
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||
private String gwInstanceId;
|
||||
|
||||
@Id
|
||||
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||
private String bizDivCode = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||
private String clientId = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||
private String inboundAdapter = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||
private String outboundAdapter = "NONE";
|
||||
|
||||
@Column(name = "TOTAL_CNT", nullable = false)
|
||||
private Long totalCnt = 0L;
|
||||
|
||||
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||
private Long successCnt = 0L;
|
||||
|
||||
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||
private Long timeoutCnt = 0L;
|
||||
|
||||
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||
private Long systemErrCnt = 0L;
|
||||
|
||||
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||
private Long bizErrCnt = 0L;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 통계 시간별 복합키
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatsHourId implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private LocalDateTime statTime;
|
||||
private String apiName;
|
||||
private String gwInstanceId;
|
||||
private String bizDivCode;
|
||||
private String clientId;
|
||||
private String inboundAdapter;
|
||||
private String outboundAdapter;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* API Gateway 월별 처리 통계 (AGWAPP.API_STATS_MONTH).
|
||||
*
|
||||
* STAT_TIME 은 YYYYMM 문자열. 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||
* 포털은 읽기 전용으로만 사용한다. 사용 컬럼(카운트)만 매핑한다.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "API_STATS_MONTH")
|
||||
@IdClass(ApiStatsMonthId.class)
|
||||
public class ApiStatsMonth implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "STAT_TIME", nullable = false, length = 6)
|
||||
private String statTime; // YYYYMM format
|
||||
|
||||
@Id
|
||||
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||
private String apiName;
|
||||
|
||||
@Id
|
||||
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||
private String gwInstanceId;
|
||||
|
||||
@Id
|
||||
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||
private String bizDivCode = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||
private String clientId = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||
private String inboundAdapter = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||
private String outboundAdapter = "NONE";
|
||||
|
||||
@Column(name = "TOTAL_CNT", nullable = false)
|
||||
private Long totalCnt = 0L;
|
||||
|
||||
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||
private Long successCnt = 0L;
|
||||
|
||||
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||
private Long timeoutCnt = 0L;
|
||||
|
||||
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||
private Long systemErrCnt = 0L;
|
||||
|
||||
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||
private Long bizErrCnt = 0L;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 통계 월별 복합키
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatsMonthId implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String statTime; // YYYYMM format
|
||||
private String apiName;
|
||||
private String gwInstanceId;
|
||||
private String bizDivCode;
|
||||
private String clientId;
|
||||
private String inboundAdapter;
|
||||
private String outboundAdapter;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
|
||||
*
|
||||
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
|
||||
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "API_STATUS")
|
||||
@Data
|
||||
public class GwApiStatus {
|
||||
|
||||
/** EAI 서비스명 */
|
||||
@Id
|
||||
@Column(name = "EAISVCNAME", length = 30)
|
||||
private String eaisvcname;
|
||||
|
||||
/** N 정상 / C 점검 / D 지연 / E 장애 */
|
||||
@Column(name = "STATUS_CODE", length = 1)
|
||||
private String statusCode;
|
||||
|
||||
@Column(name = "MODIFIED_BY", length = 20)
|
||||
private String modifiedBy;
|
||||
|
||||
@Column(name = "MODIFIED_DATE")
|
||||
private LocalDateTime modifiedDate;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* GW 인증 Client 정보 (AGWAPP.TSEAIAU01) — 읽기 전용.
|
||||
*
|
||||
* API 통계의 org 스코핑에 사용한다: 통계 테이블에는 org_id 가 없으므로
|
||||
* TSEAIAU01.ORGID 로 org 의 CLIENTID 목록을 구해 API_STATS_*.CLIENT_ID 와 매칭한다.
|
||||
* (admin ApiUseStatsService 도 API_STATS.CLIENT_ID = TSEAIAU01.CLIENTID 로 조인)
|
||||
*
|
||||
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "TSEAIAU01")
|
||||
public class GwAuthClient implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "CLIENTID", nullable = false, length = 256)
|
||||
private String clientId;
|
||||
|
||||
@Column(name = "CLIENTNAME", length = 150)
|
||||
private String clientName;
|
||||
|
||||
@Column(name = "ORGID", length = 38)
|
||||
private String orgId;
|
||||
|
||||
@Column(name = "APPSTATUS", length = 1)
|
||||
private String appStatus;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* GW 인터페이스(서비스) 정보 (AGWAPP.TSEAIHE01) — 읽기 전용.
|
||||
*
|
||||
* API_STATS_*.API_NAME 은 인터페이스 ID(=EAISVCNAME)이며, 화면 표시용 실제 명칭은 EAISVCDESC 다.
|
||||
* (admin ApiUseStatsService 도 API_STATS.API_NAME = TSEAIHE01.EAISVCNAME 으로 조인)
|
||||
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "TSEAIHE01")
|
||||
public class GwSvcInfo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "EAISVCNAME", nullable = false, length = 30)
|
||||
private String svcName; // 인터페이스 ID (= API_STATS.API_NAME)
|
||||
|
||||
@Column(name = "EAISVCDESC", length = 400)
|
||||
private String svcDesc; // 인터페이스 표시 명칭
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDay;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDayId;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_DAY(AGWAPP) 조회 리포지토리.
|
||||
* 월별 조회의 "현재 월" 분(아직 API_STATS_MONTH 미집계)을 일 범위로 합산하는 데 사용한다.
|
||||
*/
|
||||
public interface ApiStatsDayRepository extends JpaRepository<ApiStatsDay, ApiStatsDayId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (일자 범위, 지정 clientId 집합).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
/**
|
||||
* 일자별 누적 통계 (월별 조회 하단 표에 사용 — 월별이라도 일별로 표기).
|
||||
* 생성자 표현식 안의 FUNCTION 은 Hibernate 5.6 이 타입 해석을 못 하므로 Object[] 로 받는다.
|
||||
* row: [0]=일자(String yyyy-MM-dd), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
|
||||
*/
|
||||
@Query("SELECT FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD'), " +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||
"GROUP BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD') " +
|
||||
"ORDER BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD')")
|
||||
List<Object[]> findPeriodByDay(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHour;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHourId;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_HOUR(AGWAPP) 조회 리포지토리.
|
||||
*
|
||||
* 통계 수치는 보존기간이 긴 API_STATS_DAY 를 사용하되, "오늘"은 아직 DAY 로 집계되지 않았으므로
|
||||
* (일 집계 잡은 익일 실행) 오늘분만 HOUR 에서 합산해 DAY 결과에 더한다. 최신 집계 시각도 HOUR 로 산출.
|
||||
*/
|
||||
public interface ApiStatsHourRepository extends JpaRepository<ApiStatsHour, ApiStatsHourId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (시각 범위, 지정 clientId 집합) — 오늘분 합산용.
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsHour s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startTime AND :endTime")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계) — 오늘분 합산용.
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsHour s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startTime AND :endTime " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 최신 집계 시각 (안내 문구용). 데이터 없으면 null.
|
||||
*/
|
||||
@Query("SELECT MAX(s.statTime) FROM ApiStatsHour s WHERE s.clientId IN :clientIds")
|
||||
LocalDateTime findLatestStatTime(@Param("clientIds") List<String> clientIds);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonth;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonthId;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_MONTH(AGWAPP) 통계 조회 리포지토리 (월별 모드).
|
||||
* STAT_TIME 은 YYYYMM 문자열이므로 문자열 범위 비교로 조회한다.
|
||||
*/
|
||||
public interface ApiStatsMonthRepository extends JpaRepository<ApiStatsMonth, ApiStatsMonthId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (YYYYMM 범위, 지정 clientId 집합).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startMonth AND :endMonth")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startMonth") String startMonth,
|
||||
@Param("endMonth") String endMonth);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startMonth AND :endMonth " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startMonth") String startMonth,
|
||||
@Param("endMonth") String endMonth);
|
||||
|
||||
/**
|
||||
* 조회 가능한 월 목록 (YYYYMM, 최신순).
|
||||
*/
|
||||
@Query("SELECT DISTINCT s.statTime FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds ORDER BY s.statTime DESC")
|
||||
List<String> findAvailableMonths(@Param("clientIds") List<String> clientIds);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwApiStatus;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
|
||||
|
||||
/**
|
||||
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
|
||||
*/
|
||||
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
|
||||
Optional<LocalDateTime> findLastModifiedDate();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwAuthClient;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* TSEAIAU01(AGWAPP 인증 Client) 조회 — API 통계 org 스코핑용.
|
||||
* PTL_ORG.ID == TSEAIAU01.ORGID (1:N), TSEAIAU01.CLIENTID == API_STATS_*.CLIENT_ID.
|
||||
*/
|
||||
public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, String> {
|
||||
|
||||
/**
|
||||
* org 소속 클라이언트 목록 (앱 선택 드롭다운용).
|
||||
*/
|
||||
List<GwAuthClient> findByOrgIdOrderByClientNameAsc(String orgId);
|
||||
|
||||
/**
|
||||
* org 소속 CLIENTID 목록 (통계 필터용).
|
||||
*/
|
||||
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
||||
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwSvcInfo;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* TSEAIHE01(AGWAPP 인터페이스 정보) 조회 — API_NAME(인터페이스 ID) → 표시명(EAISVCDESC) 매핑용.
|
||||
*/
|
||||
public interface GwSvcInfoRepository extends JpaRepository<GwSvcInfo, String> {
|
||||
|
||||
List<GwSvcInfo> findBySvcNameIn(Collection<String> svcNames);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
||||
|
||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||
|
||||
public PortalApplication() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
portal_logger.info("##### PortalApplication Start #####");
|
||||
|
||||
@@ -15,7 +15,11 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
@Controller
|
||||
@RequestMapping("/agreements")
|
||||
public class AgreementsController {
|
||||
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
|
||||
public static final String TERMS_AGREEMENTS = "fragment/djbank/terms_agreements";
|
||||
|
||||
/** 개인정보처리방침은 약관 페이지에서 분리되어 제주은행 공식 사이트 외부 링크로 대체됨 */
|
||||
public static final String PRIVACY_POLICY_EXTERNAL_URL =
|
||||
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
|
||||
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
|
||||
@@ -28,28 +32,49 @@ public class AgreementsController {
|
||||
public String showTerms(@RequestParam(required = false) String tab,
|
||||
@RequestParam(required = false) String publishedOn,
|
||||
Model model) {
|
||||
String currentTab = tab != null ? tab : "terms";
|
||||
|
||||
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
|
||||
if ("privacy".equals(currentTab)) {
|
||||
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
|
||||
}
|
||||
|
||||
AgreementType type;
|
||||
if ("privacy".equals(tab)) {
|
||||
type = AgreementType.PRIVACY_POLICY;
|
||||
} else {
|
||||
switch (currentTab) {
|
||||
case "privacy-collect":
|
||||
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
|
||||
type = AgreementType.PRIVACY_COLLECT;
|
||||
break;
|
||||
case "notification":
|
||||
type = AgreementType.NOTIFICATION_CONSENT;
|
||||
break;
|
||||
case "terms":
|
||||
default:
|
||||
currentTab = "terms";
|
||||
type = AgreementType.TERMS_OF_USE;
|
||||
break;
|
||||
}
|
||||
|
||||
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
|
||||
model.addAttribute("agreementsList", agreementsList);
|
||||
|
||||
AgreementsDTO selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
|
||||
// 해당 약관 종류가 아직 시드되지 않았을 수 있으므로 빈 리스트 방어
|
||||
AgreementsDTO selectedAgreement = null;
|
||||
if (agreementsList != null && !agreementsList.isEmpty()) {
|
||||
selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
|
||||
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
|
||||
}
|
||||
|
||||
model.addAttribute("selectedAgreement", selectedAgreement);
|
||||
model.addAttribute("selectedDate", publishedOn);
|
||||
|
||||
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
|
||||
model.addAttribute("isPrivacyPolicy", type == AgreementType.PRIVACY_POLICY);
|
||||
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
|
||||
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
|
||||
model.addAttribute("agreementTitle", type.getDescription());
|
||||
model.addAttribute("agreementType", type.getCode());
|
||||
|
||||
model.addAttribute("currentTab", tab != null ? tab : "terms");
|
||||
model.addAttribute("currentTab", currentTab);
|
||||
|
||||
return TERMS_AGREEMENTS;
|
||||
}
|
||||
|
||||
@@ -71,8 +71,9 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
||||
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
|
||||
}
|
||||
|
||||
// 2. 개인정보수집동의서 처리
|
||||
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
|
||||
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
|
||||
if (privacyCollectType != null
|
||||
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
|
||||
Optional<Agreements> privacyCollect = agreementsService
|
||||
.findLastesBeforeDate(
|
||||
privacyCollectType,
|
||||
@@ -199,6 +200,10 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
||||
defaultAgreement.setName("개인정보처리방침");
|
||||
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
break;
|
||||
case "PRIVACY_COLLECT":
|
||||
defaultAgreement.setName("개인정보수집동의서");
|
||||
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
break;
|
||||
case "PRIVACY_COLLECT_IND":
|
||||
defaultAgreement.setName("개인용 개인정보수집동의서");
|
||||
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -30,6 +32,7 @@ public class ApiController {
|
||||
private final ApiService apiService;
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final ApiSearchFacade apiSearchFacade;
|
||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||
|
||||
@@ -38,7 +41,24 @@ public class ApiController {
|
||||
if (id == null) {
|
||||
return "redirect:/apis/common";
|
||||
}
|
||||
populateDetailModel(id, model);
|
||||
model.addAttribute("activeTab", "api-info");
|
||||
return "apps/apis/mainApiDetail";
|
||||
}
|
||||
|
||||
// 테스트베드를 API 정보와 별도 URL로 분리(딥링크·북마크 가능). 미인증 사용자도 페이지 진입은
|
||||
// 허용하되, 실제 테스트베드(Swagger)는 인증 사용자에게만 렌더하고 미인증에는 로그인 안내를 노출한다.
|
||||
@GetMapping("/detail/testbed")
|
||||
public String apidetailTestbed(@RequestParam(value = "id", required = false) String id, ModelMap model) {
|
||||
if (id == null) {
|
||||
return "redirect:/apis/common";
|
||||
}
|
||||
populateDetailModel(id, model);
|
||||
model.addAttribute("activeTab", "testbed");
|
||||
return "apps/apis/mainApiDetail";
|
||||
}
|
||||
|
||||
private void populateDetailModel(String id, ModelMap model) {
|
||||
ApiSpecInfoDto api = apiService.selectDetail(id);
|
||||
if (api == null) {
|
||||
throw new NotFoundException(NOT_FOUND_MESSAGE);
|
||||
@@ -46,10 +66,16 @@ public class ApiController {
|
||||
|
||||
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
|
||||
|
||||
// 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
|
||||
ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
|
||||
if (apiGroup != null) {
|
||||
api.setApiGroupName(apiGroup.getGroupName());
|
||||
}
|
||||
|
||||
model.addAttribute("apiSpecInfo", api);
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
return "apps/apis/mainApiDetail";
|
||||
model.addAttribute("authenticated", SecurityUtil.isAuthenticated());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -62,12 +88,18 @@ public class ApiController {
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
|
||||
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
|
||||
|
||||
return "apps/apis/mainApiList";
|
||||
}
|
||||
|
||||
@GetMapping("/testbed/api")
|
||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
String selectedApiServiceName = "API 서비스 선택";
|
||||
String selectedServiceId = "";
|
||||
boolean idExists = false;
|
||||
@@ -93,6 +125,10 @@ public class ApiController {
|
||||
|
||||
@GetMapping("/testbed")
|
||||
public String testbedByApiService(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
String selectedApiServiceName = "API 서비스 선택";
|
||||
boolean idExists = false;
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
@@ -22,35 +22,46 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/apis")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TestbedSpecController {
|
||||
|
||||
@Autowired
|
||||
private ApiSpecInfoService apiSpecInfoService;
|
||||
private final ApiSpecInfoService apiSpecInfoService;
|
||||
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> getSwagger(@PathVariable String id) {
|
||||
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(서버 → GW 주소 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
private String buildSpecJson(String id, HttpServletRequest request) {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
try {
|
||||
// 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로라
|
||||
// GW 호스트를 붙이면 실재하지 않는 주소가 된다(servers 없음 → 문서 origin 사용).
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
|
||||
return ResponseEntity.ok().body(content);
|
||||
return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read default token api spec file", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
|
||||
if (!spec.isPresent()) {
|
||||
StringUtils.hasText(spec.get().getTestbedSpec());
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
}
|
||||
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
|
||||
|
||||
return serverRewriter.rewriteServerToGateway(spec.get().getTestbedSpec(), request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.apis.filter;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -10,6 +12,9 @@ import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -20,15 +25,85 @@ public class APISender {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
||||
|
||||
public String requestPost(String uri, String requestBody) throws IOException {
|
||||
/** 응답 charset 을 Content-Type 에서 얻지 못할 때 사용할 기본값. */
|
||||
private static final String DEFAULT_CHARSET = "UTF-8";
|
||||
|
||||
/**
|
||||
* 프록시 대상(GW/mock) 응답을 상태코드까지 포함해 전달하기 위한 홀더.
|
||||
*
|
||||
* <p>본문만 반환하면 대상이 4xx/5xx 를 내려도 호출측이 200 으로 되돌려주게 되므로
|
||||
* 상태코드와 Content-Type 을 함께 담는다.</p>
|
||||
*/
|
||||
public static class ApiResponse {
|
||||
|
||||
private final int status;
|
||||
private final String body;
|
||||
private final String contentType;
|
||||
private final Map<String, List<String>> headers;
|
||||
|
||||
public ApiResponse(int status, String body, String contentType, Map<String, List<String>> headers) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.contentType = contentType;
|
||||
this.headers = headers == null
|
||||
? Collections.<String, List<String>>emptyMap()
|
||||
: Collections.unmodifiableMap(headers);
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 대상 응답의 Content-Type 원본 (없으면 null). */
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/** 대상 응답 헤더 (상태줄 의사헤더인 null 키는 제외). 값은 헤더당 복수 가능. */
|
||||
public Map<String, List<String>> getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ApiResponse{status=" + status + ", contentType=" + contentType
|
||||
+ ", bodyLen=" + (body == null ? 0 : body.length())
|
||||
+ ", headers=" + headers.keySet() + "}";
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
|
||||
this.gatewayProperty = gatewayProperty;
|
||||
}
|
||||
|
||||
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
|
||||
private void applyTimeout(HttpURLConnection connection) {
|
||||
int t = gatewayProperty.timeoutMillis();
|
||||
connection.setConnectTimeout(t);
|
||||
connection.setReadTimeout(t);
|
||||
}
|
||||
|
||||
public ApiResponse requestPost(String uri, String requestBody) throws IOException {
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
|
||||
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
|
||||
}
|
||||
|
||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||
|
||||
String response = getResponse(connection);
|
||||
ApiResponse response = getResponse(connection);
|
||||
|
||||
connection.disconnect();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender POST(json) 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -54,10 +129,11 @@ public class APISender {
|
||||
return uriBuilder.toString();
|
||||
}
|
||||
|
||||
public String requestGet(String uri, Map<String, String> headers, Map<String, String[]> params) throws IOException {
|
||||
public ApiResponse requestGet(String uri, Map<String, String> headers, Map<String, String[]> params) throws IOException {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
@@ -69,19 +145,24 @@ public class APISender {
|
||||
}
|
||||
connection.setDoOutput(true);
|
||||
|
||||
String response = getResponse(connection);
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
|
||||
}
|
||||
|
||||
ApiResponse response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender GET 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public String requestPost(String uri, Map<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
|
||||
public ApiResponse requestPost(String uri, Map<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
connection.setRequestMethod("POST");
|
||||
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
@@ -93,27 +174,42 @@ public class APISender {
|
||||
}
|
||||
connection.setDoOutput(true);
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
// client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
|
||||
logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
|
||||
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
|
||||
}
|
||||
|
||||
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
|
||||
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
|
||||
outputStream.write(requestBodyBytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
String response = getResponse(connection);
|
||||
ApiResponse response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender POST 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
private static String getResponse(HttpURLConnection connection) throws IOException {
|
||||
/**
|
||||
* 대상 응답을 상태코드·Content-Type·본문으로 읽는다.
|
||||
*
|
||||
* <p>4xx/5xx 는 {@code getInputStream()} 이 IOException 을 던지므로 errorStream 으로 본문을 읽고,
|
||||
* 본문이 아예 없는 응답(errorStream == null)은 빈 문자열로 처리한다.</p>
|
||||
*/
|
||||
private static ApiResponse getResponse(HttpURLConnection connection) throws IOException {
|
||||
int responseCode = connection.getResponseCode();
|
||||
String contentType = connection.getContentType();
|
||||
StringBuilder response = new StringBuilder();
|
||||
|
||||
try (InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream(); InputStreamReader isr = new InputStreamReader(stream);
|
||||
InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream();
|
||||
if (stream != null) {
|
||||
try (InputStreamReader isr = new InputStreamReader(stream, charsetOf(contentType));
|
||||
BufferedReader reader = new BufferedReader(isr)) {
|
||||
|
||||
String line;
|
||||
@@ -121,14 +217,43 @@ public class APISender {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
return new ApiResponse(responseCode, response.toString(), contentType, copyHeaders(connection));
|
||||
}
|
||||
|
||||
/** 대상 응답 헤더 복사. {@code getHeaderFields()} 의 null 키(상태줄)는 제외. */
|
||||
private static Map<String, List<String>> copyHeaders(HttpURLConnection connection) {
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<String>> entry : connection.getHeaderFields().entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
headers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Content-Type 의 charset 파라미터를 파싱. 없거나 인식 불가면 UTF-8. */
|
||||
private static String charsetOf(String contentType) {
|
||||
if (contentType != null) {
|
||||
for (String part : contentType.split(";")) {
|
||||
String token = part.trim();
|
||||
if (token.toLowerCase().startsWith("charset=")) {
|
||||
String charset = token.substring("charset=".length()).replace("\"", "").trim();
|
||||
if (!charset.isEmpty() && java.nio.charset.Charset.isSupported(charset)) {
|
||||
return charset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return DEFAULT_CHARSET;
|
||||
}
|
||||
|
||||
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
URL endpoint = new URL(uri);
|
||||
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.eactive.apim.portal.apps.apis.filter;
|
||||
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
/**
|
||||
* API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
|
||||
*
|
||||
* <p>logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
|
||||
* 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.</p>
|
||||
*
|
||||
* <p>마스킹 정책:</p>
|
||||
* <ul>
|
||||
* <li>secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹</li>
|
||||
* <li>그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹</li>
|
||||
* <li>JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class ApiTesterAuditLogger {
|
||||
|
||||
private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/** 값 전체를 마스킹할 키(소문자 비교) */
|
||||
private static final Set<String> SECRET_KEYS = new HashSet<>(Arrays.asList(
|
||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||
"api_key", "apikey", "access_token", "refresh_token", "authorization"));
|
||||
|
||||
/** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
|
||||
private static final String[] AUDIT_HEADERS = {
|
||||
"content-type", "accept", "referer", "origin", "x-forwarded-for",
|
||||
"original-api-id", "authorization"};
|
||||
|
||||
/** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
|
||||
private static final int JSON_LOG_MAX_LENGTH = 2000;
|
||||
|
||||
/** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
|
||||
private static final int NON_JSON_PREVIEW_LENGTH = 8;
|
||||
|
||||
private ApiTesterAuditLogger() {
|
||||
}
|
||||
|
||||
/** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
|
||||
public static String newAuditId() {
|
||||
return UUID.randomUUID().toString().substring(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
|
||||
*
|
||||
* @param targetUrl original-url 헤더 값 (없으면 null)
|
||||
* @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
|
||||
* @param tokenRequest OAuth 토큰 발급 요청 여부
|
||||
* @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
|
||||
*/
|
||||
public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
|
||||
String gatewayMode, boolean tokenRequest, String body) {
|
||||
try {
|
||||
StringBuilder sb = new StringBuilder(256);
|
||||
sb.append("REQ [").append(auditId).append(']');
|
||||
sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
|
||||
sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
|
||||
sb.append(" user=").append(currentUser());
|
||||
sb.append(" method=").append(request.getMethod());
|
||||
sb.append(" mode=").append(gatewayMode);
|
||||
sb.append(" token=").append(tokenRequest);
|
||||
sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
|
||||
sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
|
||||
sb.append(" headers=").append(buildHeaderSummary(request));
|
||||
sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
|
||||
auditLogger.info(sb.toString());
|
||||
} catch (Exception e) {
|
||||
auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
|
||||
public static void logResult(String auditId, int status, String type, long elapsedMillis) {
|
||||
auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 요청 정보 구성
|
||||
// =========================================================================
|
||||
|
||||
/** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
|
||||
private static String currentUser() {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
|
||||
return "anonymous";
|
||||
}
|
||||
String name = auth.getName();
|
||||
return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
|
||||
private static String buildHeaderSummary(HttpServletRequest request) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (String name : AUDIT_HEADERS) {
|
||||
String value = request.getHeader(name);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (!first) {
|
||||
sb.append(", ");
|
||||
}
|
||||
first = false;
|
||||
sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
|
||||
}
|
||||
return sb.append('}').toString();
|
||||
}
|
||||
|
||||
/** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
|
||||
private static String maskHeaderValue(String name, String value) {
|
||||
if (!SECRET_KEYS.contains(name.toLowerCase())) {
|
||||
return value;
|
||||
}
|
||||
int space = value.indexOf(' ');
|
||||
if (space > 0) {
|
||||
return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
|
||||
}
|
||||
return partialMask(value);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 본문 마스킹
|
||||
// =========================================================================
|
||||
|
||||
private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
|
||||
if (body == null || body.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
// 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
|
||||
if (tokenRequest) {
|
||||
return "\"" + maskFormBody(body) + "\"";
|
||||
}
|
||||
// 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
|
||||
if (contentType != null && contentType.toLowerCase().contains("json")) {
|
||||
String maskedJson = tryMaskJson(body);
|
||||
if (maskedJson != null) {
|
||||
return maskedJson;
|
||||
}
|
||||
}
|
||||
return nonJsonSummary(body);
|
||||
}
|
||||
|
||||
/** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
|
||||
private static String maskFormBody(String body) {
|
||||
StringBuilder sb = new StringBuilder(body.length());
|
||||
String[] pairs = body.split("&");
|
||||
for (int i = 0; i < pairs.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
int eq = pairs[i].indexOf('=');
|
||||
if (eq < 0) {
|
||||
sb.append(partialMask(pairs[i]));
|
||||
continue;
|
||||
}
|
||||
String key = pairs[i].substring(0, eq);
|
||||
String value = pairs[i].substring(eq + 1);
|
||||
sb.append(key).append('=');
|
||||
sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
|
||||
}
|
||||
return sanitize(sb.toString());
|
||||
}
|
||||
|
||||
/** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
|
||||
private static String maskQueryValues(String url) {
|
||||
int qs = url.indexOf('?');
|
||||
if (qs < 0) {
|
||||
return url;
|
||||
}
|
||||
return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
|
||||
}
|
||||
|
||||
/** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
|
||||
private static String tryMaskJson(String body) {
|
||||
try {
|
||||
JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
|
||||
String out = OBJECT_MAPPER.writeValueAsString(masked);
|
||||
if (out.length() > JSON_LOG_MAX_LENGTH) {
|
||||
out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
|
||||
}
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
|
||||
private static JsonNode maskJsonNode(JsonNode node) {
|
||||
if (node.isObject()) {
|
||||
ObjectNode obj = (ObjectNode) node;
|
||||
Iterator<String> names = obj.fieldNames();
|
||||
Set<String> fieldNames = new HashSet<>();
|
||||
while (names.hasNext()) {
|
||||
fieldNames.add(names.next());
|
||||
}
|
||||
for (String field : fieldNames) {
|
||||
if (SECRET_KEYS.contains(field.toLowerCase())) {
|
||||
obj.set(field, TextNode.valueOf("*****"));
|
||||
} else {
|
||||
obj.set(field, maskJsonNode(obj.get(field)));
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
ArrayNode arr = (ArrayNode) node;
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
arr.set(i, maskJsonNode(arr.get(i)));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
if (node.isNull() || node.isMissingNode()) {
|
||||
return node;
|
||||
}
|
||||
return TextNode.valueOf(partialMask(node.asText()));
|
||||
}
|
||||
|
||||
/** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
|
||||
private static String nonJsonSummary(String body) {
|
||||
int bytes = body.getBytes(StandardCharsets.UTF_8).length;
|
||||
String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
|
||||
? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
|
||||
return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 공통 helper
|
||||
// =========================================================================
|
||||
|
||||
/** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
|
||||
private static String partialMask(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int len = value.length();
|
||||
if (len <= 2) {
|
||||
return stars(len);
|
||||
}
|
||||
int visible = Math.min(4, Math.max(1, len / 3));
|
||||
return value.substring(0, visible) + "***";
|
||||
}
|
||||
|
||||
private static String stars(int count) {
|
||||
char[] arr = new char[count];
|
||||
Arrays.fill(arr, '*');
|
||||
return new String(arr);
|
||||
}
|
||||
|
||||
/** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
|
||||
private static String sanitize(String value) {
|
||||
if (value == null) {
|
||||
return "-";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == '"') {
|
||||
sb.append('\'');
|
||||
} else if (c == '\r' || c == '\n' || c == '\t') {
|
||||
sb.append(' ');
|
||||
} else if (c < 0x20) {
|
||||
sb.append('?');
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,20 @@ package com.eactive.apim.portal.apps.apis.filter;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
@@ -20,6 +27,7 @@ import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.annotation.WebFilter;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -28,6 +36,13 @@ public class ApiTesterFilter implements Filter {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApiTesterFilter.class);
|
||||
|
||||
/** 대상 응답에서 클라이언트로 되돌리지 않는 헤더 (소문자 비교). */
|
||||
private static final Set<String> BLOCKED_RESPONSE_HEADERS = new HashSet<>(Arrays.asList(
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailer", "transfer-encoding", "upgrade",
|
||||
"content-length", "content-encoding", "content-type",
|
||||
"set-cookie", "set-cookie2"));
|
||||
|
||||
@Override
|
||||
public void init(FilterConfig filterConfig) throws ServletException {
|
||||
logger.debug("ApiTesterFilter initialized");
|
||||
@@ -65,16 +80,44 @@ public class ApiTesterFilter implements Filter {
|
||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||
String url = httpServletRequest.getHeader("original-url");
|
||||
|
||||
if (url.contains("/api/v1/oauth/token")){
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
String body = sb.toString();
|
||||
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
|
||||
String auditId = ApiTesterAuditLogger.newAuditId();
|
||||
long auditStart = System.currentTimeMillis();
|
||||
String auditType = "-";
|
||||
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
|
||||
String proxyTarget = null;
|
||||
|
||||
// Parse request parameters
|
||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
|
||||
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
|
||||
System.currentTimeMillis() - auditStart);
|
||||
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
|
||||
// 반환하기 위해 try 로 감싼다.
|
||||
try {
|
||||
|
||||
// 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
|
||||
// - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
|
||||
// - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
|
||||
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
|
||||
boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
|
||||
boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
|
||||
|
||||
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
|
||||
String requestBody = readBody(httpServletRequest);
|
||||
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
|
||||
mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
|
||||
|
||||
if (tokenRequest) {
|
||||
String body = requestBody;
|
||||
|
||||
if (mockTokenRequest) {
|
||||
auditType = "TOKEN_MOCK";
|
||||
// mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
@@ -86,7 +129,7 @@ public class ApiTesterFilter implements Filter {
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
@@ -95,62 +138,318 @@ public class ApiTesterFilter implements Filter {
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(token);
|
||||
} else {
|
||||
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||
auditType = "TOKEN_GW";
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||
headers.put("Accept", "application/json");
|
||||
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
|
||||
proxyTarget = target;
|
||||
if (logger.isDebugEnabled()) {
|
||||
// client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
|
||||
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
|
||||
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
|
||||
}
|
||||
APISender.ApiResponse tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
|
||||
writeUpstream(response, tokenResponse);
|
||||
}
|
||||
|
||||
} else {
|
||||
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
||||
// 스펙 조회는 original-api-id 헤더(Swagger UI 가 x-original-api-id 확장에서 전달) 우선.
|
||||
// mock/gw 응답유형은 서버주소가 mockUrl·GW base 로 치환돼 original-url 의 path 가 저장된
|
||||
// api_url 과 일치하지 않으므로 URL 매칭만으로는 스펙을 찾지 못한다.
|
||||
ApiSpecInfoDto apiSpecInfoDto = selectSpec(apiSpecInfoDtoService, httpServletRequest, url);
|
||||
|
||||
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
|
||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||
if (apiSpecInfoDto == null) {
|
||||
auditType = "SPEC_NOT_FOUND";
|
||||
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
String responseType = apiSpecInfoDto.getResponseType();
|
||||
|
||||
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||
auditType = "SAMPLE";
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||
} else {
|
||||
String mockUrl = apiSpecInfoDto.getMockUrl();
|
||||
String method = apiSpecInfoDto.getApiMethod();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
return;
|
||||
}
|
||||
|
||||
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
|
||||
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||
auditType = gw ? "GW" : "MOCK";
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String header = headerNames.nextElement();
|
||||
headers.put(header, httpServletRequest.getHeader(header));
|
||||
}
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
|
||||
String originalUrl = headers.get("original-url");
|
||||
//sprlit original url with ? get the second part then split with & put into paramMap
|
||||
|
||||
String[] originalUrlArr = originalUrl.split("\\?");
|
||||
if (originalUrlArr.length > 1) {
|
||||
String[] paramArr = originalUrlArr[1].split("&");
|
||||
for (String param : paramArr) {
|
||||
String[] paramKeyValue = param.split("=");
|
||||
if (paramKeyValue.length > 1) {
|
||||
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers.remove("original-url");
|
||||
headers.remove("original-api-id");
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
|
||||
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
|
||||
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
|
||||
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
|
||||
|
||||
String responseStr = "";
|
||||
if (method.equalsIgnoreCase("post")) {
|
||||
String targetUri;
|
||||
Map<String, String[]> paramMap;
|
||||
if (gw) {
|
||||
// original-url 은 브라우저가 spec 서버주소(대외)로 만든 값이라 포탈 서버에서 도달하지 못할
|
||||
// 수 있다. path/query 만 떼어 대내 base-url 에 재조립한 주소로 forward 한다.
|
||||
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
|
||||
headers.remove("host");
|
||||
headers.remove("Host");
|
||||
targetUri = gatewayProperty.resolveGatewayCallUrl(url);
|
||||
paramMap = new HashMap<>();
|
||||
} else {
|
||||
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
|
||||
targetUri = apiSpecInfoDto.getMockUrl();
|
||||
paramMap = extractQueryParams(url);
|
||||
}
|
||||
|
||||
proxyTarget = targetUri;
|
||||
if (logger.isDebugEnabled()) {
|
||||
// GW SERVICE_NOT_FOUND(어댑터 URI 미등록)·AUTH_FAIL 진단용:
|
||||
// 스펙 식별/응답유형, 실제 forward 대상, 전달 헤더(민감값 마스킹), 본문 길이를 남긴다.
|
||||
logger.debug("{} forward - auditId={}, apiId={}, apiUrl={}, apiMethod={}, responseType={}, originalUrl={}, target={}, bodyLen={}, headers={}",
|
||||
auditType, auditId, apiSpecInfoDto.getApiId(), apiSpecInfoDto.getApiUrl(),
|
||||
apiSpecInfoDto.getApiMethod(), responseType, url, targetUri,
|
||||
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
|
||||
}
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
APISender.ApiResponse upstream;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
upstream = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||
} else {
|
||||
upstream = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
}
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("{} response - auditId={}, target={}, status={}, respLen={}, preview={}",
|
||||
auditType, auditId, targetUri, upstream.getStatus(),
|
||||
upstream.getBody() == null ? 0 : upstream.getBody().length(), previewOf(upstream.getBody()));
|
||||
}
|
||||
writeUpstream(response, upstream);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
|
||||
logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
|
||||
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
|
||||
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (IOException e) {
|
||||
// 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
|
||||
logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
|
||||
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
|
||||
} catch (Exception e) {
|
||||
// 그 외 예기치 못한 오류도 JSON 으로 반환
|
||||
logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
|
||||
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} finally {
|
||||
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
|
||||
System.currentTimeMillis() - auditStart);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 본문 전체를 문자열로 읽는다.
|
||||
*
|
||||
* <p>form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
|
||||
* 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
|
||||
* (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
|
||||
* 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.</p>
|
||||
*/
|
||||
private String readBody(HttpServletRequest request) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
BufferedReader reader = request.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
String body = sb.toString();
|
||||
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
|
||||
if (sb.length() == 0 && isFormUrlEncoded(request)) {
|
||||
String rebuilt = rebuildFormBodyFromParams(request);
|
||||
if (!rebuilt.isEmpty()) {
|
||||
logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
|
||||
return rebuilt;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
|
||||
private boolean isFormUrlEncoded(HttpServletRequest request) {
|
||||
String contentType = request.getContentType();
|
||||
return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
/** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
|
||||
private String rebuildFormBodyFromParams(HttpServletRequest request) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
for (String value : entry.getValue()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
try {
|
||||
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
|
||||
.append('=')
|
||||
.append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 호출 대상 API 명세를 찾는다. {@code original-api-id} 헤더가 있으면 API ID 로, 없으면 기존처럼
|
||||
* {@code original-url} 의 path + 메서드로 조회한다. 없으면 null.
|
||||
*
|
||||
* <p>API ID 는 클라이언트가 보내는 값이므로 URL 조회와 동일하게 <b>포탈 게시(display_yn='Y')</b> 인
|
||||
* 스펙만 허용한다 — 비공개 API 가 ID 추측으로 호출되지 않도록.</p>
|
||||
*/
|
||||
private ApiSpecInfoDto selectSpec(ApiService apiService, HttpServletRequest request, String url) {
|
||||
String apiId = request.getHeader("original-api-id");
|
||||
if (apiId != null && !apiId.trim().isEmpty()) {
|
||||
ApiSpecInfoDto dto = apiService.selectDetail(apiId.trim());
|
||||
if (dto != null && "Y".equalsIgnoreCase(dto.getDisplayYn())) {
|
||||
return dto;
|
||||
}
|
||||
logger.debug("original-api-id 로 게시된 스펙을 찾지 못해 URL 매칭으로 폴백 - apiId={}", apiId);
|
||||
}
|
||||
return apiService.selectDetailByURLAndMethod(parseUri(url), request.getMethod());
|
||||
}
|
||||
|
||||
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
|
||||
private Map<String, String[]> extractQueryParams(String originalUrl) {
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
String[] parts = originalUrl.split("\\?");
|
||||
if (parts.length > 1) {
|
||||
for (String param : parts[1].split("&")) {
|
||||
String[] kv = param.split("=");
|
||||
if (kv.length > 1) {
|
||||
paramMap.put(kv[0], new String[]{kv[1]});
|
||||
}
|
||||
}
|
||||
}
|
||||
return paramMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프록시 대상(GW/mock) 응답을 상태코드·헤더·본문 그대로 클라이언트에 전달한다.
|
||||
*
|
||||
* <p>대상이 404/400 을 내려도 200 으로 포장되지 않도록 상태코드를 그대로 세팅한다.
|
||||
* 단 아래 헤더는 전달하지 않는다.</p>
|
||||
* <ul>
|
||||
* <li>hop-by-hop 헤더(connection/keep-alive/transfer-encoding 등) — 연결 단위 헤더라 재전송 대상 아님</li>
|
||||
* <li>content-length / content-encoding — 본문을 문자열로 다시 쓰므로 원본 길이·압축 정보가 맞지 않음</li>
|
||||
* <li>set-cookie — 대상 쿠키가 포탈 도메인에 심겨 세션 쿠키를 덮어쓸 수 있어 차단</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void writeUpstream(ServletResponse response, APISender.ApiResponse upstream) throws IOException {
|
||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||
httpResponse.setStatus(upstream.getStatus());
|
||||
|
||||
for (Map.Entry<String, List<String>> entry : upstream.getHeaders().entrySet()) {
|
||||
String name = entry.getKey();
|
||||
if (isBlockedResponseHeader(name) || entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
boolean first = true;
|
||||
for (String value : entry.getValue()) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (first) {
|
||||
httpResponse.setHeader(name, value);
|
||||
first = false;
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
|
||||
httpResponse.addHeader(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Type 은 응답 문자셋까지 결정하므로 헤더 복사와 별개로 마지막에 확정한다.
|
||||
String contentType = upstream.getContentType();
|
||||
response.setContentType(contentType == null || contentType.trim().isEmpty()
|
||||
? "application/json" : contentType);
|
||||
|
||||
response.getWriter().println(upstream.getBody() == null ? "" : upstream.getBody());
|
||||
}
|
||||
|
||||
/** 클라이언트로 되돌리면 안 되는 응답 헤더인지. */
|
||||
private boolean isBlockedResponseHeader(String name) {
|
||||
return name == null || BLOCKED_RESPONSE_HEADERS.contains(name.toLowerCase());
|
||||
}
|
||||
|
||||
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
|
||||
private String maskHeaders(Map<String, String> headers) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||
if (sb.length() > 1) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(e.getKey()).append(':').append(StringMaskingUtil.maskHeaderValue(e.getKey(), e.getValue()));
|
||||
}
|
||||
return sb.append('}').toString();
|
||||
}
|
||||
|
||||
/** 응답 body debug 프리뷰 — 앞 300자까지만 (개행 제거). */
|
||||
private String previewOf(String body) {
|
||||
if (body == null) {
|
||||
return "null";
|
||||
}
|
||||
String flat = body.replaceAll("\\s+", " ").trim();
|
||||
return flat.length() > 300 ? flat.substring(0, 300) + "…" : flat;
|
||||
}
|
||||
|
||||
private void writeJson(ServletResponse response, int status, String json) throws IOException {
|
||||
((HttpServletResponse) response).setStatus(status);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
response.getWriter().println(json);
|
||||
}
|
||||
|
||||
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
|
||||
private String escapeJson(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
|
||||
@@ -11,8 +11,14 @@ public interface ApiSpecMapper {
|
||||
|
||||
ApiSpecInfoDto mapToDto(ApiSpecInfo apiSpecInfo);
|
||||
|
||||
/**
|
||||
* 조회 결과가 없으면 {@code null} 을 반환한다.
|
||||
*
|
||||
* <p>빈 DTO 를 반환하면 호출측의 "스펙 없음" 분기가 동작하지 않고 모든 필드가 null 인 DTO 로
|
||||
* 진행돼(예: {@code responseType == null} → sample 취급) 오동작한다.</p>
|
||||
*/
|
||||
default ApiSpecInfoDto map(Optional<ApiSpecInfo> optionalApiSpecInfo) {
|
||||
return optionalApiSpecInfo.map(this::mapToDto).orElse(new ApiSpecInfoDto());
|
||||
return optionalApiSpecInfo.map(this::mapToDto).orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.app.controller;
|
||||
|
||||
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||
import com.eactive.apim.portal.approval.statemachine.InvalidApprovalTransitionException;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
@@ -11,6 +12,10 @@ import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
||||
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
@@ -18,9 +23,12 @@ import com.eactive.apim.portal.file.service.FileTypeDetector;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -44,8 +52,9 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/myapikey")
|
||||
@RequestMapping("/clients")
|
||||
@RequiredArgsConstructor
|
||||
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
|
||||
public class MyAppController {
|
||||
@@ -77,6 +86,8 @@ public class MyAppController {
|
||||
private final ApiService apiService;
|
||||
private final ApiServiceHelper apiServiceHelper;
|
||||
private final FileTypeDetector fileTypeDetector;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
||||
|
||||
@@ -93,8 +104,28 @@ public class MyAppController {
|
||||
List<AppRequest> appRequests = appServiceFacade.getPendingApiKeyList(user.getPortalOrg());
|
||||
List<ClientDTO> apikeys = appServiceFacade.getApikeyList(user.getPortalOrg());
|
||||
|
||||
model.addAttribute("appRequests", appRequests);
|
||||
// 기존 클라이언트에 걸린 변경/해지 신청은 별도 카드로 내지 않고 해당 클라이언트 카드의 배지로 흡수한다.
|
||||
// (같은 이름의 카드가 둘로 보여 클라이언트가 두 개인 것처럼 읽히기 때문)
|
||||
// 신규(NEW) 신청처럼 아직 클라이언트가 없는 건만 신청 카드로 남긴다.
|
||||
Set<String> ownedClientIds = apikeys.stream()
|
||||
.map(ClientDTO::getClientid)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<String, AppRequest> openRequestByClient = new java.util.HashMap<>();
|
||||
List<AppRequest> standaloneRequests = new java.util.ArrayList<>();
|
||||
for (AppRequest request : appRequests) {
|
||||
String clientId = request.getClientId();
|
||||
if (StringUtils.isNotBlank(clientId) && ownedClientIds.contains(clientId)
|
||||
&& openRequestByClient.putIfAbsent(clientId, request) == null) {
|
||||
continue;
|
||||
}
|
||||
standaloneRequests.add(request);
|
||||
}
|
||||
|
||||
model.addAttribute("appRequests", standaloneRequests);
|
||||
model.addAttribute("apiKeys", apikeys);
|
||||
model.addAttribute("openRequestByClient", openRequestByClient);
|
||||
return new ModelAndView(API_KEY_LIST);
|
||||
}
|
||||
|
||||
@@ -108,14 +139,14 @@ public class MyAppController {
|
||||
@Secured("ROLE_APP")
|
||||
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
|
||||
|
||||
if (appRequest == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// API 목록 조회 및 설정
|
||||
@@ -146,14 +177,14 @@ public class MyAppController {
|
||||
@Secured("ROLE_APP")
|
||||
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
|
||||
|
||||
if (apiKey == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// API 목록에 서비스 정보 추가
|
||||
@@ -168,7 +199,17 @@ public class MyAppController {
|
||||
}
|
||||
});
|
||||
|
||||
// Client Secret은 초기 HTML에 담지 않는다. (비번 확인 후 서버가 1회만 반환)
|
||||
boolean secretAvailable = StringUtils.isNotBlank(apiKey.getClientsecret());
|
||||
apiKey.setClientsecret(null);
|
||||
|
||||
model.addAttribute("apiKey", apiKey);
|
||||
model.addAttribute("secretAvailable", secretAvailable);
|
||||
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
|
||||
// 해지 불가 사유(변경 신청 진행 중 등) — 클릭 시 사전검사가 이 사유를 안내하고 2FA 로 넘어가지 않는다
|
||||
model.addAttribute("deleteBlockReason", appServiceFacade.resolveDeleteBlockReason(id));
|
||||
// 걸려 있는 미완료 변경/해지 신청 — 상태 표시 + 취소 버튼용(결재 미개시 건도 포함)
|
||||
model.addAttribute("openRequest", appServiceFacade.findOpenRequest(id));
|
||||
|
||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||
}
|
||||
@@ -207,49 +248,112 @@ public class MyAppController {
|
||||
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
|
||||
result.put("success", true);
|
||||
result.put("message", "신청이 취소되었습니다.");
|
||||
} catch (Exception e) {
|
||||
} catch (InvalidApprovalTransitionException e) {
|
||||
result.put("success", false);
|
||||
result.put("message", "신청 취소 중 오류가 발생했습니다: " + e.getMessage());
|
||||
result.put("message", "내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.");
|
||||
} catch (IllegalStateException e) {
|
||||
log.error("API Key 신청 취소 중 GW 차단 실패. id={}", id, e);
|
||||
result.put("success", false);
|
||||
result.put("message", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("API Key 신청 취소 실패. id={}", id, e);
|
||||
result.put("success", false);
|
||||
result.put("message", "신청 취소 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key를 삭제합니다.
|
||||
* AJAX 요청을 지원하기 위해 @ResponseBody를 사용하여 JSON 응답 반환
|
||||
* API 이용 해지를 신청합니다. (AppRequestType.DELETE 결재 신청 생성)
|
||||
* 즉시 차단/삭제하지 않으며, eapim-admin 관리자 승인 시점에 GW 차단/삭제와
|
||||
* PTL_CREDENTIAL 삭제가 실행됩니다. 승인 전까지 API는 정상 동작합니다.
|
||||
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#APP_KEY_DELETE} 인터셉터 가드)가 담당합니다.
|
||||
*
|
||||
* @param requestData 요청 데이터 (clientId와 type 포함)
|
||||
* @param requestData 요청 데이터 (clientId, reason)
|
||||
* @return 성공/실패 결과를 담은 Map
|
||||
*/
|
||||
/**
|
||||
* 해지 신청 사전검사. 진행 중인 변경/해지 결재가 있어 신청이 불가한지 알려줍니다.
|
||||
*
|
||||
* <p>step-up 2FA 가 걸린 {@code /clients/api_key_delete} <b>이전</b>에 호출해,
|
||||
* 어차피 거절될 요청으로 2FA 를 반복 유도하지 않도록 한다(2FA 가드 대상 경로가 아니다).
|
||||
* 최종 판정은 {@code createDeleteRequest} 가 다시 수행하므로 이 검사는 안내 목적이다.</p>
|
||||
*/
|
||||
@GetMapping("/credential/delete-precheck")
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
@ResponseBody
|
||||
public Map<String, Object> deleteApiKeyPrecheck(@RequestParam("clientId") String clientId) {
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
String orgId = user.getPortalOrg().getId();
|
||||
|
||||
// 소유권 확인 (다른 조직 인증키의 결재 진행 상태 노출 방지)
|
||||
if (clientId == null || clientId.trim().isEmpty()
|
||||
|| appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||
result.put("blocked", true);
|
||||
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
String blockReason = appServiceFacade.resolveDeleteBlockReason(clientId);
|
||||
result.put("blocked", blockReason != null);
|
||||
result.put("msg", blockReason);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/api_key_delete")
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
@ResponseBody
|
||||
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
try {
|
||||
String clientId = requestData.get("clientId");
|
||||
String type = requestData.get("type");
|
||||
|
||||
if (clientId == null || clientId.trim().isEmpty()) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "클라이언트 ID가 필요합니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
// API Key 즉시 삭제 (승인 프로세스 없이)
|
||||
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
|
||||
|
||||
result.put("success", true);
|
||||
result.put("msg", "API Key가 삭제되었습니다.");
|
||||
} catch (Exception e) {
|
||||
String reason = requestData.get("reason");
|
||||
if (reason == null || reason.trim().isEmpty()) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
||||
result.put("msg", "해지 사유를 입력해 주세요.");
|
||||
return result;
|
||||
}
|
||||
if (reason.length() > 1000) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "해지 사유는 1000자 이내로 입력해 주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
String orgId = user.getPortalOrg().getId();
|
||||
|
||||
// 1. 소유권 확인 (다른 조직의 인증키 해지 방지)
|
||||
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. 해지 신청 생성 + 결재 개시 (GW/credential 은 승인 시점에 admin 이 처리)
|
||||
try {
|
||||
appServiceFacade.createDeleteRequest(clientId, reason.trim(), user.getPortalOrg());
|
||||
} catch (IllegalStateException e) {
|
||||
result.put("success", false);
|
||||
result.put("msg", e.getMessage());
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("API 이용 해지 신청 실패 - clientId={}", clientId, e);
|
||||
result.put("success", false);
|
||||
result.put("msg", UserErrorMessageResolver.resolveAsHtml(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
result.put("success", true);
|
||||
result.put("msg", "해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -282,6 +386,51 @@ public class MyAppController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
|
||||
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
|
||||
* 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
|
||||
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
|
||||
*
|
||||
* @param requestData clientId 포함
|
||||
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
|
||||
*/
|
||||
@PostMapping("/credential/reveal-secret")
|
||||
@Secured("ROLE_APP")
|
||||
@ResponseBody
|
||||
public Map<String, Object> revealClientSecret(@RequestBody Map<String, String> requestData) {
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
String clientId = requestData.get("clientId");
|
||||
|
||||
if (clientId == null || clientId.trim().isEmpty()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "클라이언트 ID가 필요합니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
// 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
|
||||
try {
|
||||
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
|
||||
if (secret == null) {
|
||||
result.put("success", false);
|
||||
result.put("alreadyRevealed", true);
|
||||
result.put("message", "이미 1회 노출되어 삭제된 인증정보입니다.");
|
||||
return result;
|
||||
}
|
||||
result.put("success", true);
|
||||
result.put("secret", secret);
|
||||
} catch (Exception e) {
|
||||
log.error("Client Secret 노출 처리 실패 - clientId={}", clientId, e);
|
||||
result.put("success", false);
|
||||
result.put("message", "인증정보 조회 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 요청 이력을 페이지 형태로 조회합니다.
|
||||
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
||||
@@ -344,7 +493,7 @@ public class MyAppController {
|
||||
@Secured("ROLE_API_KEY_REQUEST_VIEW")
|
||||
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey/api_key_request/history");
|
||||
return new ModelAndView("redirect:/clients/api_key_request/history");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -442,7 +591,7 @@ public class MyAppController {
|
||||
registration.setIpWhitelistFromString(ipWhitelist);
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
return new ModelAndView("redirect:/clients/register/step2");
|
||||
|
||||
} catch (Exception e) {
|
||||
setupStepModel(model, 1);
|
||||
@@ -507,7 +656,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!registration.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
|
||||
@@ -538,7 +687,7 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
// 1단계로 리다이렉트
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -556,22 +705,16 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!registration.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
// API 선택 검증
|
||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
}
|
||||
|
||||
// 선택된 API를 세션에 저장
|
||||
registration.setSelectedApis(selectedApis);
|
||||
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
|
||||
registration.setSelectedApis(selectedApis != null ? selectedApis : new ArrayList<>());
|
||||
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!registration.isComplete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -588,12 +731,12 @@ public class MyAppController {
|
||||
// 성공적으로 완료된 후 세션 초기화
|
||||
sessionStatus.setComplete();
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/register/step3");
|
||||
return new ModelAndView("redirect:/clients/register/step3");
|
||||
|
||||
} catch (Exception e) {
|
||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
return new ModelAndView("redirect:/clients/register/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,7 +754,7 @@ public class MyAppController {
|
||||
|
||||
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
||||
if (registrationSuccess == null || !registrationSuccess) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 결과 페이지 표시용 속성 설정
|
||||
@@ -630,7 +773,7 @@ public class MyAppController {
|
||||
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||
// 등록과 관련된 세션 데이터 초기화
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/myapikey";
|
||||
return "redirect:/clients";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -674,13 +817,15 @@ public class MyAppController {
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
public ModelAndView modifyStep1(
|
||||
@RequestParam(value = "clientId", required = false) String clientId,
|
||||
@RequestParam(value = "goto", required = false) String gotoStep,
|
||||
@RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
|
||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (clientId == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -688,7 +833,7 @@ public class MyAppController {
|
||||
// 기존 API Key 정보 조회
|
||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
|
||||
if (apiKey == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 새로운 수정 세션 시작시에만 초기화
|
||||
@@ -730,6 +875,16 @@ public class MyAppController {
|
||||
model.addAttribute("apiKeyModification", modification);
|
||||
}
|
||||
|
||||
// API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
|
||||
// → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
|
||||
if ("apis".equals(gotoStep)) {
|
||||
String redirectUrl = "redirect:/clients/modify/step2";
|
||||
if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
|
||||
redirectUrl += "?apiApplyToast=" + apiApplyToast;
|
||||
}
|
||||
return new ModelAndView(redirectUrl);
|
||||
}
|
||||
|
||||
// Step 모델 설정
|
||||
setupStepModel(model, 1);
|
||||
model.addAttribute("userOrg", user.getPortalOrg());
|
||||
@@ -781,7 +936,7 @@ public class MyAppController {
|
||||
modification.setIpWhitelistFromString(ipWhitelist);
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
|
||||
} catch (Exception e) {
|
||||
setupStepModel(model, 1);
|
||||
@@ -803,7 +958,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!modification.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// 서비스 카테고리와 API 목록 가져오기
|
||||
@@ -813,6 +968,8 @@ public class MyAppController {
|
||||
setupStepModel(model, 2);
|
||||
model.addAttribute("apiServices", apiServices);
|
||||
model.addAttribute("modification", modification);
|
||||
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
|
||||
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
|
||||
|
||||
return new ModelAndView(API_KEY_MODIFY_STEP2);
|
||||
}
|
||||
@@ -834,7 +991,7 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
// 1단계로 리다이렉트
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -847,18 +1004,19 @@ public class MyAppController {
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
HttpSession session,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!modification.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// API 선택 검증
|
||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
|
||||
// 선택된 API를 세션에 저장
|
||||
@@ -867,7 +1025,15 @@ public class MyAppController {
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!modification.isComplete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||
// 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
|
||||
if (isAppModifyTwofaRequired()
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
|
||||
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -884,12 +1050,12 @@ public class MyAppController {
|
||||
// 성공적으로 완료된 후 세션 초기화
|
||||
sessionStatus.setComplete();
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/modify/step3");
|
||||
return new ModelAndView("redirect:/clients/modify/step3");
|
||||
|
||||
} catch (Exception e) {
|
||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,7 +1072,7 @@ public class MyAppController {
|
||||
|
||||
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
||||
if (modificationComplete == null || !modificationComplete) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 결과 페이지 표시용 속성 설정
|
||||
@@ -935,12 +1101,18 @@ public class MyAppController {
|
||||
|
||||
// clientId가 있으면 상세 페이지로, 없으면 목록으로
|
||||
if (clientId != null && !clientId.isEmpty()) {
|
||||
return "redirect:/myapikey/credential_detail?id=" + clientId;
|
||||
return "redirect:/clients/credential_detail?id=" + clientId;
|
||||
} else {
|
||||
return "redirect:/myapikey";
|
||||
return "redirect:/clients";
|
||||
}
|
||||
}
|
||||
|
||||
/** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
|
||||
private boolean isAppModifyTwofaRequired() {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@ public class ApiKeyRegistrationDTO implements Serializable {
|
||||
private byte[] appIconData;
|
||||
private String appIconContentType;
|
||||
|
||||
@NotBlank(message = "앱 이름을 입력해주세요.")
|
||||
@Length(max = 100, message = "앱 이름은 100자를 초과할 수 없습니다.")
|
||||
@NotBlank(message = "클라이언트 이름을 입력해주세요.")
|
||||
@Length(max = 100, message = "클라이언트 이름은 100자를 초과할 수 없습니다.")
|
||||
private String appName;
|
||||
|
||||
@NotBlank(message = "앱 설명을 입력해주세요.")
|
||||
@Length(max = 500, message = "앱 설명은 500자를 초과할 수 없습니다.")
|
||||
@NotBlank(message = "클라이언트 설명을 입력해주세요.")
|
||||
@Length(max = 500, message = "클라이언트 설명은 500자를 초과할 수 없습니다.")
|
||||
private String appDescription;
|
||||
|
||||
private String callbackUrl;
|
||||
@@ -99,6 +99,7 @@ public class ApiKeyRegistrationDTO implements Serializable {
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return isStep1Complete() && isStep2Complete();
|
||||
// API 선택은 선택 사항이므로 기본 정보(Step1)만 완료되면 등록 가능하다.
|
||||
return isStep1Complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -25,8 +24,7 @@ public class AppRequestDTO {
|
||||
|
||||
private ApprovalDTO approval;
|
||||
|
||||
@NotEmpty
|
||||
private String apiList = ""; //comma separated api id list
|
||||
private String apiList = ""; //comma separated api id list (미선택 허용)
|
||||
|
||||
private String apiGroupList = ""; //comma separated api group id list
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.eactive.apim.portal.apps.app.service;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||
*
|
||||
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminGatewayClient {
|
||||
|
||||
private static final String PROP_GROUP = "Portal";
|
||||
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/**
|
||||
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||
*
|
||||
* @param clientId 차단할 클라이언트 ID
|
||||
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||
*/
|
||||
public void blockClient(String clientId) {
|
||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||
|
||||
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
|
||||
|
||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||
|
||||
Map<?, ?> body = response.getBody();
|
||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||
if (!success) {
|
||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
||||
}
|
||||
|
||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
|
||||
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
||||
import com.eactive.apim.portal.approval.entity.Approval;
|
||||
import com.eactive.apim.portal.approval.statemachine.CreatedState;
|
||||
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
|
||||
import com.eactive.apim.portal.approval.statemachine.RequestedState;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
@@ -31,6 +32,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -59,21 +61,47 @@ public class AppServiceFacade {
|
||||
private final ApiServiceHelper apiServiceHelper;
|
||||
private final FileService fileService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AdminGatewayClient adminGatewayClient;
|
||||
|
||||
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
||||
|
||||
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
|
||||
return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList());
|
||||
// 최근 수정(발급/변경) 순으로 정렬. 수정일 없는 건은 뒤로.
|
||||
return clients.stream()
|
||||
.sorted(Comparator.comparing(Credential::getModifiedon,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.map(credentialMapper::toVo).collect(Collectors.toList());
|
||||
|
||||
}
|
||||
|
||||
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
|
||||
List<AppRequestType> types = Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE);
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
|
||||
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||
|
||||
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
|
||||
appRequests.sort(Comparator.comparingInt(this::pendingStatusRank)
|
||||
.thenComparing(AppRequest::getCreatedDate, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
|
||||
return appRequests;
|
||||
}
|
||||
|
||||
private int pendingStatusRank(AppRequest request) {
|
||||
if (request.getApproval() == null) {
|
||||
return 3;
|
||||
}
|
||||
if (request.getApproval().getApprovalStatus() instanceof ProcessingState) {
|
||||
return 1;
|
||||
}
|
||||
if (request.getApproval().getApprovalStatus() instanceof RequestedState) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
public ClientDTO getApiKey(String orgid, String clientId) {
|
||||
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
|
||||
}
|
||||
@@ -133,8 +161,121 @@ public class AppServiceFacade {
|
||||
approvalService.beginApproval(approvalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 이용 해지(DELETE) 결재 신청을 생성하고 결재를 개시합니다.
|
||||
* GW 차단/삭제와 PTL_CREDENTIAL 삭제는 여기서 하지 않으며,
|
||||
* eapim-admin 승인 시점에 PortalAppApprovalListener 가 수행합니다.
|
||||
*
|
||||
* @throws IllegalStateException 중복 신청, 변경 신청 진행 중, 승인라인 미등록 등 사용자에게 안내할 상황
|
||||
*/
|
||||
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
|
||||
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
|
||||
String blockReason = resolveDeleteBlockReason(clientId);
|
||||
if (blockReason != null) {
|
||||
throw new IllegalStateException(blockReason);
|
||||
}
|
||||
|
||||
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
|
||||
AppRequestDTO dto = new AppRequestDTO();
|
||||
dto.setType(AppRequestType.DELETE);
|
||||
dto.setClientId(clientId);
|
||||
dto.setReason(reason);
|
||||
dto.setOrg(portalOrgMapper.toVo(portalOrg));
|
||||
|
||||
AppRequestDTO saved = createAppRequest(dto);
|
||||
|
||||
// 3. 승인라인 미등록이면 approval 이 null — 결재 없는 해지 신청은 만들지 않는다(트랜잭션 롤백)
|
||||
if (saved.getApproval() == null || saved.getApproval().getId() == null) {
|
||||
throw new IllegalStateException("APP 승인라인이 등록되어 있지 않아 해지를 신청할 수 없습니다. 관리자에게 문의해 주세요.");
|
||||
}
|
||||
|
||||
beginApproval(saved.getApproval().getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 해지 신청을 막는 사유가 있으면 안내 메시지를, 없으면 {@code null} 을 반환합니다.
|
||||
*
|
||||
* <p>미완료 해지·변경 신청이 있으면 해지를 받을 수 없다. {@link #createDeleteRequest} 의 최종 가드와
|
||||
* 화면/사전검사가 <b>같은 판정</b>({@link #findOpenRequestEntity})을 쓰도록 이 메서드 하나로 모은다.
|
||||
* (사전검사가 없으면 사용자가 step-up 2FA 를 통과한 뒤에야 차단 사실을 알게 되고,
|
||||
* 재시도할 때마다 2FA 가 반복된다.)</p>
|
||||
*/
|
||||
public String resolveDeleteBlockReason(String clientId) {
|
||||
AppRequest open = findOpenRequestEntity(clientId);
|
||||
if (open == null) {
|
||||
return null;
|
||||
}
|
||||
if (AppRequestType.DELETE.equals(open.getType())) {
|
||||
return "이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.";
|
||||
}
|
||||
return "해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 인증키에 걸려 있는 <b>미완료</b> 변경/해지 신청 1건. 없으면 {@code null}.
|
||||
* 상세 화면에서 진행 상태를 표시하고 신청을 취소할 수 있게 하는 데 사용한다.
|
||||
*/
|
||||
public AppRequestDTO findOpenRequest(String clientId) {
|
||||
AppRequest open = findOpenRequestEntity(clientId);
|
||||
return open == null ? null : appRequestMapper.toVo(open);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미완료(=결재가 끝나지 않은) 변경/해지 신청 1건.
|
||||
*
|
||||
* <p>결재가 개시되지 않아 {@code approval} 이 없는 신청도 포함한다. 이런 건은 관리자 결재함에
|
||||
* 뜨지 않아 사용자가 상태를 알 수도, 정리할 수도 없는 채로 남아 이후 신청을 계속 막는다.
|
||||
* 화면에 노출해 취소할 수 있게 하려면 여기서 잡아야 한다.</p>
|
||||
*/
|
||||
private AppRequest findOpenRequestEntity(String clientId) {
|
||||
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||
for (AppRequest r : related) {
|
||||
if (r.getApproval() == null) {
|
||||
return r;
|
||||
}
|
||||
Object status = r.getApproval().getApprovalStatus();
|
||||
if (status instanceof CreatedState
|
||||
|| status instanceof RequestedState
|
||||
|| status instanceof ProcessingState) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
|
||||
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
|
||||
*/
|
||||
public boolean hasPendingDeleteRequest(String clientId) {
|
||||
return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.DELETE)).stream()
|
||||
.anyMatch(r -> r.getApproval() != null
|
||||
&& (r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||
|| r.getApproval().getApprovalStatus() instanceof ProcessingState));
|
||||
}
|
||||
|
||||
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
|
||||
if (request.getApproval() == null) {
|
||||
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
|
||||
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
|
||||
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
|
||||
// DELETE(해지) 신청은 살아있는 클라이언트가 대상이므로 취소 시 GW 를 건드리면 안 된다.
|
||||
if (StringUtils.isNotBlank(request.getClientId())
|
||||
&& !AppRequestType.DELETE.equals(request.getType())) {
|
||||
try {
|
||||
adminGatewayClient.blockClient(request.getClientId());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.", e);
|
||||
}
|
||||
}
|
||||
appRequestRepository.delete(request);
|
||||
} else {
|
||||
approvalService.cancelAppApproval(request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -164,9 +305,15 @@ public class AppServiceFacade {
|
||||
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
|
||||
|
||||
for (String apiId : apiList) {
|
||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||
// 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
|
||||
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
|
||||
if (spec == null) {
|
||||
continue;
|
||||
}
|
||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||
if (serviceDTO != null) {
|
||||
spec.setService(serviceDTO.getGroupName());
|
||||
}
|
||||
appRequest.getApiSpecList().add(spec);
|
||||
}
|
||||
}
|
||||
@@ -294,17 +441,26 @@ public class AppServiceFacade {
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key(Credential)를 즉시 삭제합니다.
|
||||
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
||||
* Client Secret을 1회 조회하고 그 즉시 DB(PTL_CREDENTIAL)에서 물리 삭제합니다.
|
||||
* 보안 정책상 비밀정보는 최초 1회만 제공되며, 조회 후에는 복구할 수 없습니다.
|
||||
*
|
||||
* @param orgId 조직 ID
|
||||
* @param clientId 삭제할 클라이언트 ID
|
||||
* @param orgId 조직 ID (소유권 확인용)
|
||||
* @param clientId 대상 클라이언트 ID
|
||||
* @return 삭제 전 Client Secret 값. 이미 노출되어 비어있으면 {@code null}
|
||||
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
|
||||
*/
|
||||
public void deleteApp(String orgId, String clientId) {
|
||||
public String revealAndDeleteClientSecret(String orgId, String clientId) {
|
||||
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
|
||||
|
||||
credentialRepository.delete(credential);
|
||||
String secret = credential.getClientsecret();
|
||||
if (StringUtils.isBlank(secret)) {
|
||||
return null; // 이미 1회 노출되어 삭제됨
|
||||
}
|
||||
|
||||
credential.setClientsecret(null); // 물리 삭제 (1회 제공)
|
||||
credentialRepository.save(credential);
|
||||
return secret;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,4 +31,21 @@ public class ApprovalDTO {
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private LocalDateTime approvalDate;
|
||||
|
||||
private String expectEndDate; //예상완료일 (yyyyMMdd)
|
||||
|
||||
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
|
||||
public String getExpectEndDateDayOfWeek() {
|
||||
if (expectEndDate == null || expectEndDate.length() < 8) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return java.time.LocalDate
|
||||
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
.getDayOfWeek()
|
||||
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.eactive.apim.portal.apps.auth;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 인증(이메일/SMS) 테스트 안내 관련 PTL_PROPERTY 접근 래퍼.
|
||||
*
|
||||
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
|
||||
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
|
||||
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
|
||||
* {@link TwoFactorProperties} 와 동일한 {@code getOrCreateProperty} 패턴을 따른다.</p>
|
||||
*
|
||||
* <p><b>prod 프로파일에서는 DB 값과 무관하게 항상 false</b> 를 반환한다(운영 환경 인증번호 노출 금지).
|
||||
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthNoticeProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final Environment environment;
|
||||
|
||||
/**
|
||||
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
|
||||
* prod 환경에서는 property 값과 무관하게 항상 false.
|
||||
*/
|
||||
public boolean isTestNoticeEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return false;
|
||||
}
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, KEY_TEST_NOTICE_ENABLED, "true",
|
||||
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,23 @@ public interface AuthNumberService {
|
||||
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||
|
||||
/**
|
||||
* 기본 TTL 로 발송하되 수신자 이름을 지정한다. 세 번째 인자가 int 인 오버로드(TTL 지정)와 혼동하지 말 것.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, String username);
|
||||
|
||||
/**
|
||||
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
||||
|
||||
/**
|
||||
* 수신자 이름을 지정해 인증번호를 발송한다. 메시지 템플릿의 %USER_NAME% 치환에 사용되며,
|
||||
* 회원가입·아이디/비밀번호 찾기처럼 사용자 이름을 알 수 없는 흐름은 "guest" 를 넘긴다.
|
||||
* username 이 비어 있으면 %USER_NAME% 은 치환되지 않고 원문이 그대로 남는다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username);
|
||||
|
||||
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||
}
|
||||
|
||||
@@ -45,17 +45,35 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||
logger.info("Sending auth number to: {} via {}", recipientKey, msgType);
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, String username) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, username);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, ttlSeconds, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username) {
|
||||
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||
|
||||
validateResendTime(recipientKey);
|
||||
|
||||
String authNumber = generator.generateAuthNumber();
|
||||
|
||||
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType);
|
||||
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType, username);
|
||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||
|
||||
storage.saveAuthNumber(recipientKey, authNumber,
|
||||
LocalDateTime.now().plusSeconds(authNumberExpirationTime));
|
||||
LocalDateTime.now().plusSeconds(ttlSeconds));
|
||||
|
||||
return authNumber;
|
||||
}
|
||||
@@ -66,17 +84,20 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
logger.info("Verifying auth number for: {}", recipientKey);
|
||||
|
||||
TwoFactorAuth storedAuth = storage.getAuthNumber(recipientKey)
|
||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요."));
|
||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요.",
|
||||
AuthNumberException.Reason.NOT_FOUND));
|
||||
|
||||
if (storedAuth.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
storage.deleteAuthNumber(recipientKey);
|
||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.");
|
||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.",
|
||||
AuthNumberException.Reason.EXPIRED);
|
||||
}
|
||||
|
||||
if (authNumber.equals(storedAuth.getAuthNumber())) {
|
||||
return true;
|
||||
} else {
|
||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.");
|
||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.",
|
||||
AuthNumberException.Reason.MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +111,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
});
|
||||
}
|
||||
|
||||
private MessageRecipient createMessageRecipient(String recipientKey, String msgType) {
|
||||
private MessageRecipient createMessageRecipient(String recipientKey, String msgType, String username) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUserId(recipientKey);
|
||||
// 메시지 템플릿 %USER_NAME% 치환용. 비어 있으면 MessageSendService 가 파라미터 자체를 넣지 않는다.
|
||||
if (username != null && !username.trim().isEmpty()) {
|
||||
recipient.setUsername(username);
|
||||
}
|
||||
if ("SMS".equalsIgnoreCase(msgType)) {
|
||||
recipient.setPhone(recipientKey);
|
||||
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
|
||||
|
||||
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
|
||||
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
|
||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class AuthNumberStorage {
|
||||
|
||||
private static final String HASH_ALGORITHM = "HmacSHA256";
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
/**
|
||||
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
|
||||
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
|
||||
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
|
||||
*/
|
||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
||||
private String hashPepper;
|
||||
|
||||
@Autowired
|
||||
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
||||
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
||||
}
|
||||
|
||||
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
|
||||
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
|
||||
String lookupKey = hashRecipient(recipientKey);
|
||||
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
|
||||
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
|
||||
auth.setRecipientKey(lookupKey);
|
||||
twoFactorAuthRepository.save(auth);
|
||||
}
|
||||
|
||||
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
||||
return twoFactorAuthRepository.findByRecipient(recipientKey);
|
||||
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
|
||||
}
|
||||
|
||||
public void deleteAuthNumber(String recipientKey) {
|
||||
twoFactorAuthRepository.deleteById(recipientKey);
|
||||
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
|
||||
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
|
||||
*/
|
||||
private String hashRecipient(String recipient) {
|
||||
if (recipient == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HASH_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
|
||||
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) {
|
||||
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
sb.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* step-up 2FA(민감기능 추가 인증) 진입 가드.
|
||||
*
|
||||
* <p>{@link StepUpProtectedPaths#isInterceptorGuarded(String)} 경로 진입 시, 유효한 1회용
|
||||
* 통과권이 없으면 2FA 를 요구한다.
|
||||
* <ul>
|
||||
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
|
||||
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
|
||||
* </ul>
|
||||
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
|
||||
*
|
||||
* <p>내 정보 변경({@code /mypage}, PASSWORD 레벨)과 비밀번호 반영({@code POST /password/change},
|
||||
* 반영 직전 2FA)은 각 컨트롤러가 직접 관장하므로 이 인터셉터 대상이 아니다.</p>
|
||||
*/
|
||||
public class StepUpAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
public StepUpAuthInterceptor(TwoFactorService twoFactorService, TwoFactorProperties twoFactorProperties) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.twoFactorProperties = twoFactorProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
if (!twoFactorProperties.isStepUpEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = request.getServletPath();
|
||||
if (!StepUpProtectedPaths.isInterceptorGuarded(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 지점별 스위치가 꺼져 있으면 해당 경로는 step-up 미적용
|
||||
if (!twoFactorProperties.isStepUpPointEnabled(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
// 세션(=인증)이 없으면 여기서 다루지 않고 보안 계층(@Secured)에 맡긴다.
|
||||
return true;
|
||||
}
|
||||
|
||||
// 1회용 통과권 소비 시도 (매번 인증: 있으면 소멸 후 통과)
|
||||
if (twoFactorService.consumeStepUpPass(session, path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("POST".equalsIgnoreCase(request.getMethod())) {
|
||||
// AJAX 지점(Secret 조회/앱 해지) → 프론트가 팝업을 띄우도록 신호
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"stepUpRequired\":true}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// GET 페이지 진입 → 챌린지 페이지로 유도(원경로+쿼리 보존)
|
||||
String returnUrl = path;
|
||||
String query = request.getQueryString();
|
||||
if (query != null && !query.isEmpty()) {
|
||||
returnUrl = returnUrl + "?" + query;
|
||||
}
|
||||
String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8.name());
|
||||
response.sendRedirect(request.getContextPath() + "/auth/2fa/challenge?returnUrl=" + encoded);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
/**
|
||||
* 완화된 step-up(PASSWORD 레벨) 확인 페이지.
|
||||
*
|
||||
* <p>{@link StepUpProtectedPaths#isPasswordGated(String)} 경로(예: {@code /mypage})는 2FA 대신
|
||||
* <b>현재 비밀번호 재확인</b>만 요구한다. 각 컨트롤러가 통과권이 없을 때 이 페이지로 유도하고,
|
||||
* 확인 성공 시 해당 경로의 통과권을 발급한 뒤 원경로로 복귀시킨다.</p>
|
||||
*
|
||||
* <p>returnUrl 은 PASSWORD 레벨 화이트리스트로만 검증·복귀하여 open redirect 를 막는다.</p>
|
||||
*/
|
||||
@Controller
|
||||
@Secured("ROLE_ACCOUNT")
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/auth/stepup")
|
||||
public class StepUpPasswordController {
|
||||
|
||||
private final UserFacade userFacade;
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
@GetMapping("/password")
|
||||
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
||||
String path = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
model.addAttribute("returnUrl", path);
|
||||
return "apps/auth/stepupPassword";
|
||||
}
|
||||
|
||||
@PostMapping("/password")
|
||||
public String verify(@RequestParam String currentPassword,
|
||||
@RequestParam(required = false) String returnUrl,
|
||||
HttpSession session, Model model) {
|
||||
String path = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
String loginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
||||
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||
twoFactorService.grantStepUpPass(session, path);
|
||||
return "redirect:" + path;
|
||||
}
|
||||
|
||||
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
model.addAttribute("returnUrl", path);
|
||||
return "apps/auth/stepupPassword";
|
||||
}
|
||||
|
||||
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
|
||||
private static String pathOf(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
int q = url.indexOf('?');
|
||||
return q >= 0 ? url.substring(0, q) : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* step-up(민감기능 추가 인증) 대상 서블릿 경로 화이트리스트 + 지점별 프로퍼티 키 + 검증 레벨.
|
||||
*
|
||||
* <p>검증 레벨(완화 정책)</p>
|
||||
* <ul>
|
||||
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
|
||||
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
|
||||
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
|
||||
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
|
||||
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>세 가지 관심사를 분리한다.</p>
|
||||
* <ol>
|
||||
* <li>{@link #isTwoFactorPurpose(String)} — 공통 2FA 팝업의 유효 대상(purpose) 화이트리스트.
|
||||
* open redirect / 임의 purpose 로의 2FA 발송·통과권 발급을 막는 데 쓴다.</li>
|
||||
* <li>{@link #isInterceptorGuarded(String)} — {@link StepUpAuthInterceptor} 가 진입 시점에
|
||||
* 자동 차단하는 경로. 비밀번호 변경(반영 직전 확인)·내 정보(별도 확인 페이지)는
|
||||
* 각 컨트롤러가 직접 관장하므로 여기서 제외한다.</li>
|
||||
* <li>{@link #isPasswordGated(String)} — 현재 비밀번호 재확인으로 보호하는 경로.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어하며
|
||||
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
|
||||
*/
|
||||
public final class StepUpProtectedPaths {
|
||||
|
||||
/** step-up 검증 레벨(완화 정책) */
|
||||
public enum Level {
|
||||
/** 공통 2FA 팝업(인증번호) */
|
||||
TWO_FACTOR,
|
||||
/** 현재 비밀번호 재확인만 */
|
||||
PASSWORD
|
||||
}
|
||||
|
||||
/** Secret 키 조회 (AJAX POST) */
|
||||
public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
|
||||
/** 앱 해지 신청 (AJAX POST) */
|
||||
public static final String APP_KEY_DELETE = "/clients/api_key_delete";
|
||||
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
|
||||
public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
|
||||
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
|
||||
public static final String MYPAGE = "/mypage";
|
||||
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
|
||||
public static final String PASSWORD_CHANGE = "/password/change";
|
||||
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
|
||||
public static final String WITHDRAW = "/withdraw";
|
||||
|
||||
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
||||
private static final String KEY_PREFIX = "two-factor.stepup.";
|
||||
|
||||
/** 경로 → 지점별 프로퍼티 키. 삽입 순서 유지(LinkedHashMap) */
|
||||
private static final Map<String, String> PATH_TO_KEY;
|
||||
/** 경로 → 검증 레벨 */
|
||||
private static final Map<String, Level> PATH_TO_LEVEL;
|
||||
/** 인터셉터가 진입 시점에 자동 차단하는 경로(2FA) */
|
||||
private static final Set<String> INTERCEPTOR_GUARDED;
|
||||
|
||||
static {
|
||||
Map<String, String> keys = new LinkedHashMap<>();
|
||||
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
|
||||
keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
|
||||
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
|
||||
keys.put(MYPAGE, KEY_PREFIX + "mypage");
|
||||
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
||||
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
|
||||
PATH_TO_KEY = Collections.unmodifiableMap(keys);
|
||||
|
||||
Map<String, Level> levels = new LinkedHashMap<>();
|
||||
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
|
||||
levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
|
||||
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
|
||||
levels.put(MYPAGE, Level.PASSWORD);
|
||||
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
|
||||
levels.put(WITHDRAW, Level.TWO_FACTOR);
|
||||
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
|
||||
|
||||
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
|
||||
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
|
||||
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
|
||||
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
|
||||
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
|
||||
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
|
||||
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
|
||||
Set<String> guarded = new java.util.LinkedHashSet<>();
|
||||
guarded.add(REVEAL_SECRET);
|
||||
guarded.add(APP_KEY_DELETE);
|
||||
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
|
||||
}
|
||||
|
||||
private StepUpProtectedPaths() {
|
||||
}
|
||||
|
||||
/** 공통 2FA 팝업의 유효 대상(purpose)인지 — open redirect / 임의 purpose 차단용 */
|
||||
public static boolean isTwoFactorPurpose(String servletPath) {
|
||||
return servletPath != null
|
||||
&& PATH_TO_LEVEL.get(servletPath) == Level.TWO_FACTOR;
|
||||
}
|
||||
|
||||
/** 인터셉터가 진입 시점에 자동 차단하는 경로인지 */
|
||||
public static boolean isInterceptorGuarded(String servletPath) {
|
||||
return servletPath != null && INTERCEPTOR_GUARDED.contains(servletPath);
|
||||
}
|
||||
|
||||
/** 현재 비밀번호 재확인으로 보호하는 경로인지(PASSWORD 레벨) */
|
||||
public static boolean isPasswordGated(String servletPath) {
|
||||
return servletPath != null && PATH_TO_LEVEL.get(servletPath) == Level.PASSWORD;
|
||||
}
|
||||
|
||||
/** 해당 경로의 지점별 활성화 프로퍼티 키. 대상 경로가 아니면 null */
|
||||
public static String propertyKeyOf(String servletPath) {
|
||||
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
|
||||
}
|
||||
|
||||
/** 지점별 프로퍼티 키 접두 */
|
||||
public static String keyPrefix() {
|
||||
return KEY_PREFIX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 만료된 2FA 인증번호(PTL_TWO_FACTOR_AUTH) 정리 스케줄러.
|
||||
*
|
||||
* <p>검증은 접근 시점 lazy 만료 검사만 하므로, 발송 후 검증 없이 방치된 레코드가 남는다.
|
||||
* 1분 주기로 만료분을 일괄 삭제한다.</p>
|
||||
*
|
||||
* <p><b>다중화(스케일아웃) 안전성:</b> 작업이 "만료된 행만" 지우는 멱등 delete 라
|
||||
* 여러 인스턴스가 동시에 실행해도 결과가 동일하고 부작용이 없다. 따라서 분산 락
|
||||
* (ShedLock 등)이 필요 없다. 동일 행을 둘이 지우려 하면 한쪽이 0건 삭제로 끝날 뿐이다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorCleanupScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TwoFactorCleanupScheduler.class);
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
@Scheduled(fixedRate = 60000)
|
||||
@Transactional
|
||||
public void cleanupExpired() {
|
||||
try {
|
||||
int deleted = twoFactorAuthRepository.deleteAllByExpiresAtBefore(LocalDateTime.now());
|
||||
if (deleted > 0) {
|
||||
log.debug("만료된 2FA 인증번호 {}건 정리", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("2FA 인증번호 정리 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 진행 중인 2FA 절차 상태. HTTP 세션에 단일 소스로 보관한다.
|
||||
*
|
||||
* <p>세션 클러스터링(stage/prod Redis/Ehcache) 대상이므로 {@link Serializable} 이다.
|
||||
* 여러 탭/페이지에서 동시에 2FA 가 발동되지 않도록, 발송 시 이 컨텍스트 존재 여부로
|
||||
* "진행 중" 을 판정하고 confirm 후 강제 종료(force)로만 새 절차를 시작한다.</p>
|
||||
*/
|
||||
public class TwoFactorContext implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public enum Mode {
|
||||
/** 로그인 1차 인증 통과 후 대기(pending) 상태의 2FA */
|
||||
LOGIN,
|
||||
/** 로그인 이후 민감기능 접근 시 추가 인증(step-up) */
|
||||
STEPUP
|
||||
}
|
||||
|
||||
private Mode mode;
|
||||
/** 발송 채널 (EMAIL | SMS) */
|
||||
private String channel;
|
||||
/** AuthNumberService 에 전달한 실제 수신처 문자열(이메일 소문자 / 휴대폰 digits). 검증 시 동일 값 사용 */
|
||||
private String recipient;
|
||||
/** step-up 대상 보호 경로(purpose). LOGIN 모드에서는 null */
|
||||
private String purpose;
|
||||
/** 발송 시각 */
|
||||
private LocalDateTime startedAt;
|
||||
/** 유효시간(초) */
|
||||
private int ttlSeconds;
|
||||
/** 검증 시도 횟수 */
|
||||
private int attempts;
|
||||
|
||||
public Mode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(Mode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public void setChannel(String channel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
public String getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
public void setRecipient(String recipient) {
|
||||
this.recipient = recipient;
|
||||
}
|
||||
|
||||
public String getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public void setPurpose(String purpose) {
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
public LocalDateTime getStartedAt() {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
public void setStartedAt(LocalDateTime startedAt) {
|
||||
this.startedAt = startedAt;
|
||||
}
|
||||
|
||||
public int getTtlSeconds() {
|
||||
return ttlSeconds;
|
||||
}
|
||||
|
||||
public void setTtlSeconds(int ttlSeconds) {
|
||||
this.ttlSeconds = ttlSeconds;
|
||||
}
|
||||
|
||||
public int getAttempts() {
|
||||
return attempts;
|
||||
}
|
||||
|
||||
public void setAttempts(int attempts) {
|
||||
this.attempts = attempts;
|
||||
}
|
||||
|
||||
public int incrementAttempts() {
|
||||
return ++this.attempts;
|
||||
}
|
||||
|
||||
/** startedAt + ttl 기준 만료 여부(세션 컨텍스트 lazy 만료 판정용) */
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return startedAt == null || startedAt.plusSeconds(ttlSeconds).isBefore(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
/**
|
||||
* 공통 2FA 팝업 백엔드. 로그인 pending·step-up 을 모두 처리한다.
|
||||
*
|
||||
* <p>수신처는 서버가 세션 대상 사용자로부터 결정하므로 클라이언트는 채널만 전달한다.
|
||||
* 모든 POST 는 세션 기반 CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/auth/2fa")
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorController {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
/** 팝업 초기 정보(채널·TTL·진행중 여부) */
|
||||
@GetMapping("/info")
|
||||
@ResponseBody
|
||||
public TwoFactorInfoResponse info(@RequestParam(required = false) String purpose, HttpSession session) {
|
||||
return twoFactorService.getInfo(session, purpose);
|
||||
}
|
||||
|
||||
/** 인증번호 발송 */
|
||||
@PostMapping("/send")
|
||||
@ResponseBody
|
||||
public TwoFactorSendResponse send(@RequestParam String channel,
|
||||
@RequestParam(required = false) String purpose,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean force,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
return twoFactorService.send(request, session, channel, purpose, force);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 */
|
||||
@PostMapping("/verify")
|
||||
@ResponseBody
|
||||
public TwoFactorVerifyResponse verify(@RequestParam String code,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
return twoFactorService.verify(request, session, code);
|
||||
}
|
||||
|
||||
/** 팝업 닫기/타이머 만료 → 2차 인증 실패 처리 */
|
||||
@PostMapping("/cancel")
|
||||
@ResponseBody
|
||||
public void cancel(@RequestParam(required = false, defaultValue = "CANCELLED") String reason,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
twoFactorService.cancel(request, session, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* step-up GET 진입 지점용 챌린지 페이지. 인터셉터가 리다이렉트하며, 화면이 공통 팝업을 자동 오픈한다.
|
||||
* returnUrl 은 보호 경로 화이트리스트로 검증(open redirect 방지)한다.
|
||||
*/
|
||||
@GetMapping("/challenge")
|
||||
public String challenge(@RequestParam(required = false) String returnUrl, Model model) {
|
||||
// returnUrl 은 쿼리스트링을 포함할 수 있으므로 경로 부분만 화이트리스트로 검증(open redirect 방지)
|
||||
String purpose = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
model.addAttribute("returnUrl", returnUrl);
|
||||
model.addAttribute("purpose", purpose);
|
||||
return "apps/auth/twoFactorChallenge";
|
||||
}
|
||||
|
||||
private static String pathOf(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
int q = url.indexOf('?');
|
||||
return q >= 0 ? url.substring(0, q) : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 2FA 관련 PTL_PROPERTY 접근 래퍼.
|
||||
*
|
||||
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례({@code session.timeout.minutes},
|
||||
* {@code org.hard-delete.enabled} 등)를 따른다.
|
||||
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
|
||||
* 생성(그룹 존재 시)하므로 별도 초기 데이터가 필요 없다. 캐시가 없어 매 호출 DB 조회지만
|
||||
* 2FA 진입 경로가 제한적이라 허용 범위다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
|
||||
public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
|
||||
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
||||
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
||||
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 로그인 2FA 활성화 여부 */
|
||||
public boolean isLoginEnabled() {
|
||||
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 2FA 적용 대상 역할인지 여부.
|
||||
* 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
|
||||
* 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
|
||||
* 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
|
||||
*/
|
||||
public boolean isLoginTargetRole(RoleCode roleCode) {
|
||||
if (roleCode == null) {
|
||||
roleCode = RoleCode.ROLE_USER;
|
||||
}
|
||||
String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
|
||||
"로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if ("ALL".equalsIgnoreCase(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
for (String token : trimmed.split(",")) {
|
||||
if (roleCode.name().equalsIgnoreCase(token.trim())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
|
||||
public boolean isStepUpEnabled() {
|
||||
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 보호 경로에 step-up 2FA 를 적용할지 여부(지점별 스위치).
|
||||
* 전체 스위치({@link #isStepUpEnabled()})가 켜진 상태에서 지점별로 개별 on/off 한다.
|
||||
* 지점 프로퍼티({@code two-factor.stepup.<지점>})의 기본값은 true(전체 스위치를 켜면 기본 전 지점 적용).
|
||||
*
|
||||
* @param servletPath 보호 경로. 매핑 키가 없으면(비보호 경로) false
|
||||
*/
|
||||
public boolean isStepUpPointEnabled(String servletPath) {
|
||||
String key = StepUpProtectedPaths.propertyKeyOf(servletPath);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
return parseBool(resolve(key, "true", "step-up 2FA 지점 적용 여부 (true/false): " + servletPath));
|
||||
}
|
||||
|
||||
/** 2FA 인증번호 유효시간(초). 기본 180초(3분) */
|
||||
public int getTtlSeconds() {
|
||||
return parseInt(resolve(KEY_TTL_SECONDS, "180", "2차 인증번호 유효시간(초)"), 180);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 시도 한도. 기본 5회 */
|
||||
public int getAttemptLimit() {
|
||||
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
||||
}
|
||||
|
||||
/** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */
|
||||
public boolean isTestNoticeEnabled() {
|
||||
return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"));
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* boolean PTL_PROPERTY 값 파싱.
|
||||
* DB 관례에 맞춰 <b>true/false</b> 문자열을 사용한다(예: {@code org.hard-delete.enabled=true}).
|
||||
* "true"(대소문자 무시)만 참으로 본다. 그 외(false/공백/null 등)는 모두 거짓.
|
||||
*/
|
||||
private static boolean parseBool(String value) {
|
||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
private static int parseInt(String value, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (Exception e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberService;
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberStorage;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorChannel;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 2FA(2차 인증/추가 인증) 공통 서비스. 로그인 pending 인증과 step-up(민감기능) 인증을 모두 처리한다.
|
||||
*
|
||||
* <p>핵심 원칙:
|
||||
* <ul>
|
||||
* <li>수신처는 서버가 세션의 대상 사용자로부터 DB 기준으로 결정한다(클라이언트는 채널만 선택).</li>
|
||||
* <li>진행 상태는 세션 {@link TwoFactorContext} 단일 소스로 관리한다.</li>
|
||||
* <li>다른 플로우가 진행 중이면 발송을 막고(inProgress), confirm 후 force 로만 강제 종료·재시작한다.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TwoFactorService.class);
|
||||
|
||||
// === 세션 attribute 키 ===
|
||||
/** 로그인 1차 인증 통과 후 대기 중인 사용자 id (존재 시 LOGIN 모드) */
|
||||
public static final String ATTR_PENDING_USER_ID = "TFA_PENDING_USER_ID";
|
||||
/** 대기 중 사용자 loginId (감사/세션 표기) */
|
||||
public static final String ATTR_PENDING_LOGIN_ID = "TFA_PENDING_LOGIN_ID";
|
||||
/** 진행 중 2FA 컨텍스트 */
|
||||
public static final String ATTR_CONTEXT = "TFA_CONTEXT";
|
||||
/** step-up 1회용 통과권 - 대상 경로 */
|
||||
public static final String ATTR_STEPUP_PASS_PATH = "TFA_STEPUP_PASS_PATH";
|
||||
/** step-up 1회용 통과권 - 발급 시각 */
|
||||
public static final String ATTR_STEPUP_PASS_AT = "TFA_STEPUP_PASS_AT";
|
||||
|
||||
/** step-up 통과권 유효시간(초). 인증 성공 후 대상 페이지 진입까지의 이동 여유분 */
|
||||
public static final int STEPUP_PASS_TTL_SECONDS = 120;
|
||||
/** 재발송/채널전환 통합 최소 간격(초) */
|
||||
private static final int RESEND_THROTTLE_SECONDS = 30;
|
||||
|
||||
private final TwoFactorProperties properties;
|
||||
private final AuthNumberService authNumberService;
|
||||
private final AuthNumberStorage authNumberStorage;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
// =========================================================================
|
||||
// INFO
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorInfoResponse getInfo(HttpSession session, String purpose) {
|
||||
TwoFactorInfoResponse res = new TwoFactorInfoResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
if (mode == null) {
|
||||
res.setAvailable(false);
|
||||
return res;
|
||||
}
|
||||
PortalUser user = resolveTargetUser(session, mode);
|
||||
if (user == null) {
|
||||
res.setAvailable(false);
|
||||
return res;
|
||||
}
|
||||
|
||||
res.setAvailable(true);
|
||||
res.setMode(mode.name());
|
||||
res.setChannels(buildChannels(user));
|
||||
res.setTtlSeconds(properties.getTtlSeconds());
|
||||
res.setTestNoticeEnabled(properties.isTestNoticeEnabled());
|
||||
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx != null && !isSameFlow(ctx, mode, purpose)) {
|
||||
res.setInProgress(true);
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다.");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SEND
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorSendResponse send(HttpServletRequest request, HttpSession session, String channel,
|
||||
String purpose, boolean force) {
|
||||
TwoFactorSendResponse res = new TwoFactorSendResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
if (mode == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("인증 대상 정보가 없습니다. 다시 시도해주세요.");
|
||||
return res;
|
||||
}
|
||||
if (mode == TwoFactorContext.Mode.STEPUP && !StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
|
||||
res.setValid(false);
|
||||
res.setMessage("허용되지 않은 요청입니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
PortalUser user = resolveTargetUser(session, mode);
|
||||
if (user == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("인증 대상 사용자를 찾을 수 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
String normalizedChannel = channel == null ? "" : channel.trim().toUpperCase();
|
||||
String recipient = resolveRecipient(user, normalizedChannel);
|
||||
if (recipient == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("선택한 방법으로 인증할 수 있는 정보가 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 진행 중 컨텍스트 처리
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx != null) {
|
||||
boolean sameFlow = isSameFlow(ctx, mode, purpose);
|
||||
if (!sameFlow) {
|
||||
if (!force) {
|
||||
res.setValid(false);
|
||||
res.setInProgress(true);
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
|
||||
return res;
|
||||
}
|
||||
discardContext(request, session, ctx); // 강제 종료(감사 기록 포함)
|
||||
} else if (ctx.getStartedAt() != null
|
||||
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
|
||||
res.setValid(false);
|
||||
res.setMessage("잠시 후에 다시 시도해 주세요.");
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
int ttl = properties.getTtlSeconds();
|
||||
String authNumber;
|
||||
try {
|
||||
authNumber = authNumberService.sendRequestAuthNumber(recipient,
|
||||
"SMS".equals(normalizedChannel) ? "SMS" : "EMAIL", ttl);
|
||||
} catch (AuthNumberException e) {
|
||||
res.setValid(false);
|
||||
res.setMessage(e.getMessage());
|
||||
return res;
|
||||
}
|
||||
|
||||
TwoFactorContext newCtx = new TwoFactorContext();
|
||||
newCtx.setMode(mode);
|
||||
newCtx.setChannel(normalizedChannel);
|
||||
newCtx.setRecipient(recipient);
|
||||
newCtx.setPurpose(mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||
newCtx.setStartedAt(LocalDateTime.now());
|
||||
newCtx.setTtlSeconds(ttl);
|
||||
newCtx.setAttempts(0);
|
||||
session.setAttribute(ATTR_CONTEXT, newCtx);
|
||||
|
||||
res.setValid(true);
|
||||
res.setMessage("인증번호를 발송하였습니다.");
|
||||
res.setTtlSeconds(ttl);
|
||||
if (properties.isTestNoticeEnabled()) {
|
||||
res.setTestAuthNumber(authNumber);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// VERIFY
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorVerifyResponse verify(HttpServletRequest request, HttpSession session, String code) {
|
||||
TwoFactorVerifyResponse res = new TwoFactorVerifyResponse();
|
||||
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx == null) {
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("인증 시간이 만료되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
if (ctx.isExpired(LocalDateTime.now())) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
int attempts = ctx.incrementAttempts();
|
||||
int limit = properties.getAttemptLimit();
|
||||
|
||||
try {
|
||||
authNumberService.verifyAuthNumber(ctx.getRecipient(), code);
|
||||
} catch (AuthNumberException e) {
|
||||
AuthNumberException.Reason reason = e.getReason();
|
||||
if (reason == AuthNumberException.Reason.EXPIRED || reason == AuthNumberException.Reason.NOT_FOUND) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
// 코드 불일치
|
||||
if (attempts >= limit) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_ATTEMPT_EXCEEDED, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("인증 시도 횟수를 초과했습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
session.setAttribute(ATTR_CONTEXT, ctx); // attempts 갱신 반영
|
||||
res.setValid(false);
|
||||
res.setRemainingAttempts(limit - attempts);
|
||||
res.setMessage("인증번호가 일치하지 않습니다. (남은 횟수 " + (limit - attempts) + "회)");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 검증 성공 — 인증번호 즉시 소비(재사용 방지, 2FA 한정)
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
return completeLogin(request, session, res);
|
||||
}
|
||||
|
||||
// STEPUP — 1회용 통과권 발급
|
||||
issueStepUpPass(session, ctx.getPurpose());
|
||||
res.setValid(true);
|
||||
res.setMessage("인증이 완료되었습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
private TwoFactorVerifyResponse completeLogin(HttpServletRequest request, HttpSession session,
|
||||
TwoFactorVerifyResponse res) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
|
||||
PortalUser user = userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||
if (user == null) {
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 1차 인증~2FA 사이 상태 변경 방어(잠금/차단/승인 취소)
|
||||
String stateError = revalidateLoginState(user);
|
||||
if (stateError != null) {
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.ACCOUNT_DISABLED, request.getHeader("User-Agent"));
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage(stateError);
|
||||
return res;
|
||||
}
|
||||
|
||||
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
|
||||
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
String redirect = loginFinalizer.finalizeLogin(user, loginId, request, LoginType.TWO_FACTOR);
|
||||
clearPending(session);
|
||||
|
||||
res.setValid(true);
|
||||
res.setRedirect(redirect);
|
||||
res.setMessage("인증이 완료되었습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CANCEL (팝업 닫기 / 타이머 만료)
|
||||
// =========================================================================
|
||||
|
||||
public void cancel(HttpServletRequest request, HttpSession session, String reason) {
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
boolean timeout = "TIMEOUT".equalsIgnoreCase(reason);
|
||||
LoginFailureReason failureReason = timeout
|
||||
? LoginFailureReason.TWO_FACTOR_TIMEOUT : LoginFailureReason.TWO_FACTOR_CANCELLED;
|
||||
|
||||
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason,
|
||||
request.getHeader("User-Agent"));
|
||||
}
|
||||
if (ctx != null && ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
|
||||
// 로그인 2FA 취소는 로그인 자체를 포기(익명 유지) → pending 제거
|
||||
if (ctx == null || ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
clearPending(session);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LOGIN pending 진입 (SuccessHandler 에서 호출)
|
||||
// =========================================================================
|
||||
|
||||
/** 로그인 1차 인증 통과 사용자를 2FA 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
|
||||
public void beginLoginChallenge(HttpSession session, PortalUser user) {
|
||||
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
|
||||
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
public boolean hasPendingLogin(HttpSession session) {
|
||||
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STEP-UP 통과권
|
||||
// =========================================================================
|
||||
|
||||
private void issueStepUpPass(HttpSession session, String path) {
|
||||
session.setAttribute(ATTR_STEPUP_PASS_PATH, path);
|
||||
session.setAttribute(ATTR_STEPUP_PASS_AT, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 보호 경로 수정 저장 직후 원경로로 되돌아가는 즉시 왕복(예: {@code /mypage} 수정 →
|
||||
* {@code redirect:/mypage})에서 중복 step-up 을 막기 위해 통과권을 재발급한다.
|
||||
*
|
||||
* <p>경로 고정 + TTL({@link #STEPUP_PASS_TTL_SECONDS}s) 로 <b>1회 왕복만</b> 커버하며,
|
||||
* 이후 새로 {@code /mypage} 에 진입하면 정상적으로 다시 인증을 요구한다("매번 인증" 유지).</p>
|
||||
*/
|
||||
public void grantStepUpPass(HttpSession session, String path) {
|
||||
if (session != null && path != null) {
|
||||
issueStepUpPass(session, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 경로에 대한 유효한 1회용 통과권이 있으면 소비(제거)하고 true 를 반환한다.
|
||||
* (매번 인증 정책 — 통과권은 즉시 소멸)
|
||||
*/
|
||||
public boolean consumeStepUpPass(HttpSession session, String servletPath) {
|
||||
Object passPath = session.getAttribute(ATTR_STEPUP_PASS_PATH);
|
||||
Object passAt = session.getAttribute(ATTR_STEPUP_PASS_AT);
|
||||
if (!(passPath instanceof String) || !(passAt instanceof LocalDateTime)) {
|
||||
return false;
|
||||
}
|
||||
boolean valid = passPath.equals(servletPath)
|
||||
&& ((LocalDateTime) passAt).plusSeconds(STEPUP_PASS_TTL_SECONDS).isAfter(LocalDateTime.now());
|
||||
// 매번 인증: 일치/불일치 무관하게 통과권은 이번 판정에서 소멸시킨다.
|
||||
session.removeAttribute(ATTR_STEPUP_PASS_PATH);
|
||||
session.removeAttribute(ATTR_STEPUP_PASS_AT);
|
||||
return valid;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 내부 helper
|
||||
// =========================================================================
|
||||
|
||||
private TwoFactorContext.Mode resolveMode(HttpSession session) {
|
||||
if (session.getAttribute(ATTR_PENDING_USER_ID) != null) {
|
||||
return TwoFactorContext.Mode.LOGIN;
|
||||
}
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return TwoFactorContext.Mode.STEPUP;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private PortalUser resolveTargetUser(HttpSession session, TwoFactorContext.Mode mode) {
|
||||
if (mode == TwoFactorContext.Mode.LOGIN) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
return userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||
}
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
// 세션 로드 이후 연락처 변경 반영을 위해 DB 재조회
|
||||
return portalUserRepository.findById(current.getId()).orElse(null);
|
||||
}
|
||||
|
||||
private List<TwoFactorChannel> buildChannels(PortalUser user) {
|
||||
List<TwoFactorChannel> channels = new ArrayList<>();
|
||||
if (StringUtils.hasText(user.getEmailAddr())) {
|
||||
channels.add(new TwoFactorChannel("EMAIL", StringMaskingUtil.maskEmail(user.getEmailAddr())));
|
||||
}
|
||||
if (StringUtils.hasText(user.getMobileNumber())) {
|
||||
channels.add(new TwoFactorChannel("SMS", StringMaskingUtil.maskMobileNumber(user.getMobileNumber())));
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
private String resolveRecipient(PortalUser user, String channel) {
|
||||
if ("EMAIL".equals(channel)) {
|
||||
return StringUtils.hasText(user.getEmailAddr()) ? user.getEmailAddr() : null;
|
||||
}
|
||||
if ("SMS".equals(channel)) {
|
||||
return StringUtils.hasText(user.getMobileNumber())
|
||||
? PhoneNumberUtil.digitsOnly(user.getMobileNumber()) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private TwoFactorContext getActiveContext(HttpSession session) {
|
||||
Object ctx = session.getAttribute(ATTR_CONTEXT);
|
||||
if (!(ctx instanceof TwoFactorContext)) {
|
||||
return null;
|
||||
}
|
||||
TwoFactorContext context = (TwoFactorContext) ctx;
|
||||
if (context.isExpired(LocalDateTime.now())) {
|
||||
// 만료 컨텍스트는 정리(감사는 verify/cancel 경로에서 처리)
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
if (context.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(context.getRecipient());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private boolean isSameFlow(TwoFactorContext ctx, TwoFactorContext.Mode mode, String purpose) {
|
||||
return ctx.getMode() == mode && Objects.equals(ctx.getPurpose(),
|
||||
mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||
}
|
||||
|
||||
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
|
||||
private void discardContext(HttpServletRequest request, HttpSession session, TwoFactorContext ctx) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.TWO_FACTOR_CANCELLED, request.getHeader("User-Agent"));
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
/** 검증 실패로 절차 종료: 인증번호 삭제 + 감사 + 컨텍스트/pending 정리 */
|
||||
private void terminateWithFailure(HttpSession session, TwoFactorContext ctx,
|
||||
LoginFailureReason reason, HttpServletRequest request) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason,
|
||||
request.getHeader("User-Agent"));
|
||||
clearPending(session);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
private void clearPending(HttpSession session) {
|
||||
session.removeAttribute(ATTR_PENDING_USER_ID);
|
||||
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
}
|
||||
|
||||
/** 1차 인증~2FA 사이 계정 상태 재검증. 문제 있으면 사용자 안내 메시지 반환, 정상이면 null */
|
||||
private String revalidateLoginState(PortalUser user) {
|
||||
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
|
||||
return "계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.";
|
||||
}
|
||||
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
|
||||
return "법인 관리자에 의해 비활성화된 계정입니다.";
|
||||
}
|
||||
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
|
||||
return "사용자 승인 대기중입니다.";
|
||||
}
|
||||
if (user.getPortalOrg() != null
|
||||
&& !PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus())) {
|
||||
return "로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/** 2FA 발송 가능 채널 1건. masked 는 화면 표기용 마스킹 수신처. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class TwoFactorChannel {
|
||||
/** EMAIL | SMS */
|
||||
private String type;
|
||||
/** 마스킹된 수신처 (예: te**@ex**.com, 010-12**-34**) */
|
||||
private String masked;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** GET /auth/2fa/info 응답. 팝업 초기 렌더용. */
|
||||
@Data
|
||||
public class TwoFactorInfoResponse {
|
||||
/** 컨텍스트 유효 여부(로그인 pending 또는 인증 사용자). false 면 팝업 진입 불가 */
|
||||
private boolean available;
|
||||
/** LOGIN | STEPUP */
|
||||
private String mode;
|
||||
/** 발송 가능 채널(휴대폰 없으면 이메일만) */
|
||||
private List<TwoFactorChannel> channels;
|
||||
/** 인증번호 유효시간(초) — 타이머 초기값 */
|
||||
private int ttlSeconds;
|
||||
/** 테스트용 인증번호 노출 여부 */
|
||||
private boolean testNoticeEnabled;
|
||||
/** 이미 진행 중인 절차 존재 여부(다른 탭/페이지) */
|
||||
private boolean inProgress;
|
||||
/** 진행 중인 절차의 안내 메시지(있으면) */
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** POST /auth/2fa/send 응답. */
|
||||
@Data
|
||||
public class TwoFactorSendResponse {
|
||||
private boolean valid;
|
||||
private String message;
|
||||
/** 타이머 유효시간(초) */
|
||||
private int ttlSeconds;
|
||||
/** 테스트용 인증번호(테스트 노출 활성 시에만 채워짐) */
|
||||
private String testAuthNumber;
|
||||
/** 이미 진행 중인 절차가 있어 발송을 막은 경우 true (confirm 후 force 재요청 유도) */
|
||||
private boolean inProgress;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** POST /auth/2fa/verify 응답. */
|
||||
@Data
|
||||
public class TwoFactorVerifyResponse {
|
||||
private boolean valid;
|
||||
private String message;
|
||||
/** LOGIN 모드 성공 시 이동 대상 URL */
|
||||
private String redirect;
|
||||
/** 실패 시 남은 시도 횟수 */
|
||||
private int remainingAttempts;
|
||||
/** 시도 초과/타임아웃 등으로 절차가 강제 종료되어 재시작이 필요한 경우 true */
|
||||
private boolean terminated;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.commission.constant;
|
||||
|
||||
/**
|
||||
* Application constants.
|
||||
*/
|
||||
public final class Constants {
|
||||
|
||||
public static final String SYSTEM_ACCOUNT = "system";
|
||||
|
||||
public static final String APIM_FOR_OBP_KEY = "x-obp-trust-system";
|
||||
|
||||
public static final String APIM_CODE_FOR_OBP_KEY = "x-obp-partnercode";
|
||||
|
||||
public static final String SMS_URL = "/api/messaging/sms";
|
||||
|
||||
public static final String COMMISSION_URL = "/api/billing/billing/findBillingForCondition";
|
||||
|
||||
public static final String COMMISSION_PRINT_URL = "/api/billing/billing/findBillingForCondition/print";
|
||||
|
||||
public static final String OBP_ORGANIZATION_URL = "/api/customer/findCustomerByBusinessManRegistrationNo/";
|
||||
|
||||
public static String LANGUAGE = "ko";
|
||||
|
||||
private Constants() {
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.commission.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.commission.constant.Constants;
|
||||
import com.eactive.apim.portal.apps.commission.dto.CommissionDTO;
|
||||
import com.eactive.apim.portal.apps.commission.dto.CommissionSearch;
|
||||
import com.eactive.apim.portal.apps.commission.exception.ErrorUtil;
|
||||
import com.eactive.apim.portal.apps.commission.service.ExternalService;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.collections.MapUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
|
||||
@Controller
|
||||
@RequestMapping(value = "/commission")
|
||||
@Secured("ROLE_APP")
|
||||
@RequiredArgsConstructor
|
||||
public class CommissionController {
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(CommissionController.class);
|
||||
|
||||
private final ExternalService externalService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
@GetMapping("/manage")
|
||||
public String commissionManagePage(Model model) {
|
||||
// 기본 정보 설정
|
||||
model.addAttribute("organization", SecurityUtil.getUserOrg());
|
||||
model.addAttribute("noOrgCode", StringUtils.isEmpty(SecurityUtil.getUserOrg().getOrgCode()));
|
||||
return "apps/commission/commissionManage";
|
||||
}
|
||||
|
||||
@GetMapping("/print/{year}/{month}")
|
||||
public String commissionPrintPage(
|
||||
@PathVariable("year") String year,
|
||||
@PathVariable("month") String month,
|
||||
Model model) {
|
||||
|
||||
CommissionSearch search = new CommissionSearch();
|
||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
||||
search.setYear(year);
|
||||
search.setMonth(month);
|
||||
|
||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
|
||||
|
||||
if (result.hasError()) {
|
||||
model.addAttribute("error", result.getError());
|
||||
return "apps/commission/commissionPrint";
|
||||
}
|
||||
|
||||
// Model에 데이터 바인딩
|
||||
model.addAttribute("commission", result.getData());
|
||||
model.addAttribute("organization", SecurityUtil.getUserOrg().getOrgName());
|
||||
model.addAttribute("year", year);
|
||||
model.addAttribute("month", month);
|
||||
model.addAttribute("toDay", java.time.LocalDate.now().toString());
|
||||
|
||||
return "apps/commission/commissionPrint";
|
||||
}
|
||||
|
||||
// ==================== API Endpoints ====================
|
||||
|
||||
@PostMapping("/print/check")
|
||||
public ResponseEntity<?> checkPrintData(@RequestBody CommissionSearch search) {
|
||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
||||
|
||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
||||
return ErrorUtil.create("fail.commission.needPartnerCode");
|
||||
}
|
||||
|
||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
|
||||
|
||||
if (result.hasError()) {
|
||||
return ErrorUtil.create("fail.commission.print", result.getError());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().body(result.getData());
|
||||
}
|
||||
|
||||
@PostMapping()
|
||||
public ResponseEntity<?> list(@RequestBody CommissionSearch search) {
|
||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
||||
|
||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
||||
return ErrorUtil.create("fail.commission.needPartnerCode");
|
||||
}
|
||||
|
||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_URL);
|
||||
|
||||
if (result.hasError()) {
|
||||
return ErrorUtil.create("fail.commission.list", result.getError());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().body(result.getData());
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
/**
|
||||
* 외부 API를 호출하여 수수료 데이터를 조회합니다.
|
||||
*
|
||||
* @param search 검색 조건
|
||||
* @param apiPath API 경로 (Constants.COMMISSION_URL 또는 Constants.COMMISSION_PRINT_URL)
|
||||
* @return 조회 결과
|
||||
*/
|
||||
private CommissionResult fetchCommissionData(CommissionSearch search, String apiPath) {
|
||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
||||
return CommissionResult.error("파트너 코드가 없습니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
String obmUrl = portalPropertyService.getPortalPropertiesAsMap("Portal").getOrDefault("obp.obm.url", "");
|
||||
String url = obmUrl + apiPath;
|
||||
|
||||
// 첫 번째 API 호출
|
||||
String result = externalService.getResponseHttpPost(url, toJson(search));
|
||||
if (StringUtils.isEmpty(result)) {
|
||||
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
|
||||
}
|
||||
|
||||
HashMap<String, Object> returnObj = objectMapper.readValue(result, HashMap.class);
|
||||
|
||||
// 에러 응답 체크
|
||||
String errorMessage = extractErrorMessage(returnObj);
|
||||
if (errorMessage != null) {
|
||||
return CommissionResult.error(errorMessage);
|
||||
}
|
||||
|
||||
// Together 관련 추가 처리 (000002-01)
|
||||
if (StringUtils.pathEquals(search.getPartnerCode(), "000002-01")) {
|
||||
log.debug("Found Together : " + search.getPartnerCode());
|
||||
|
||||
CommissionSearch togetherSearch = new CommissionSearch();
|
||||
togetherSearch.setPartnerCode("000002-02");
|
||||
togetherSearch.setYear(search.getYear());
|
||||
togetherSearch.setMonth(search.getMonth());
|
||||
|
||||
String togetherResult = externalService.getResponseHttpPost(url, toJson(togetherSearch));
|
||||
if (StringUtils.isEmpty(togetherResult)) {
|
||||
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
|
||||
}
|
||||
|
||||
HashMap<String, Object> togetherReturnObj = objectMapper.readValue(togetherResult, HashMap.class);
|
||||
log.debug("togetherLendingReturnObj : " + togetherReturnObj.toString());
|
||||
|
||||
String togetherErrorMessage = extractErrorMessage(togetherReturnObj);
|
||||
if (togetherErrorMessage != null) {
|
||||
return CommissionResult.error(togetherErrorMessage);
|
||||
}
|
||||
|
||||
HashMap<String, Object> mergeMap = mergeCommissionData(returnObj, togetherReturnObj);
|
||||
result = objectMapper.writeValueAsString(mergeMap);
|
||||
}
|
||||
|
||||
CommissionDTO commissionDTO = objectMapper.readValue(result, CommissionDTO.class);
|
||||
return CommissionResult.success(commissionDTO);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn(e.getMessage(), e);
|
||||
return CommissionResult.error("수수료 조회에 실패하였습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답에서 에러 메시지를 추출합니다.
|
||||
*/
|
||||
private String extractErrorMessage(HashMap<String, Object> response) {
|
||||
if (!StringUtils.isEmpty(response.get("error"))) {
|
||||
HashMap<String, Object> error = (HashMap) response.get("error");
|
||||
return error.get("message").toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String toJson(CommissionSearch search) {
|
||||
try {
|
||||
Map<String, String> jsonMap = new HashMap<>();
|
||||
jsonMap.put("partnerCode", search.getPartnerCode());
|
||||
// 월을 두 자리로 패딩 (예: 1 -> 01, 12 -> 12)
|
||||
String paddedMonth = String.format("%02d", Integer.parseInt(search.getMonth()));
|
||||
jsonMap.put("targetOnMonth", search.getYear() + paddedMonth);
|
||||
|
||||
return objectMapper.writeValueAsString(jsonMap);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize CommissionSearch to JSON", e);
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse double value from HashMap
|
||||
* Handles both numeric types and string representations
|
||||
*/
|
||||
private double safeGetDouble(HashMap<String, Object> map, String key) {
|
||||
Object value = map.get(key);
|
||||
if (value == null) {
|
||||
return 0.0;
|
||||
}
|
||||
try {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).doubleValue();
|
||||
} else if (value instanceof String) {
|
||||
return Double.parseDouble((String) value);
|
||||
}
|
||||
return 0.0;
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Failed to parse double value for key: {} with value: {}", key, value);
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
public HashMap<String, Object> mergeCommissionData(HashMap<String, Object> o1, HashMap<String, Object> o2) {
|
||||
|
||||
HashMap<String, Object> mergeMap = new HashMap<String, Object>();
|
||||
|
||||
String o1Status = MapUtils.getString(o1, "status");
|
||||
String o2Status = MapUtils.getString(o2, "status");
|
||||
String resultStatus = null;
|
||||
if (Integer.parseInt(o1Status) <= Integer.parseInt(o2Status)) {
|
||||
resultStatus = o1Status;
|
||||
} else {
|
||||
resultStatus = o2Status;
|
||||
}
|
||||
double apiTotalValueSum = safeGetDouble(o1, "apiTotalValueSum") + safeGetDouble(o2, "apiTotalValueSum");
|
||||
double apiSum = safeGetDouble(o1, "apiSum") + safeGetDouble(o2, "apiSum");
|
||||
double apiTotalValueSum2 = safeGetDouble(o1, "apiTotalValueSum2") + safeGetDouble(o2, "apiTotalValueSum2");
|
||||
double apiSum2 = safeGetDouble(o1, "apiSum2") + safeGetDouble(o2, "apiSum2");
|
||||
double productTotalValueCountSum = safeGetDouble(o1, "productTotalValueCountSum") + safeGetDouble(o2, "productTotalValueCountSum");
|
||||
double productTotalValueAmountSum = safeGetDouble(o1, "productTotalValueAmountSum") + safeGetDouble(o2, "productTotalValueAmountSum");
|
||||
double productSum = safeGetDouble(o1, "productSum") + safeGetDouble(o2, "productSum");
|
||||
double productTotalValueCountSum2 = safeGetDouble(o1, "productTotalValueCountSum2") + safeGetDouble(o2, "productTotalValueCountSum2");
|
||||
double productTotalValueAmountSum2 = safeGetDouble(o1, "productTotalValueAmountSum2") + safeGetDouble(o2, "productTotalValueAmountSum2");
|
||||
double productSum2 = safeGetDouble(o1, "productSum2") + safeGetDouble(o2, "productSum2");
|
||||
double totalValueCountSum = safeGetDouble(o1, "totalValueCountSum") + safeGetDouble(o2, "totalValueCountSum");
|
||||
double totalValueCountSum2 = safeGetDouble(o1, "totalValueCountSum2") + safeGetDouble(o2, "totalValueCountSum2");
|
||||
double totalValueAmountSum = safeGetDouble(o1, "totalValueAmountSum") + safeGetDouble(o2, "totalValueAmountSum");
|
||||
double totalValueAmountSum2 = safeGetDouble(o1, "totalValueAmountSum2") + safeGetDouble(o2, "totalValueAmountSum2");
|
||||
double sum = safeGetDouble(o1, "sum") + safeGetDouble(o2, "sum");
|
||||
double sum2 = safeGetDouble(o1, "sum2") + safeGetDouble(o2, "sum2");
|
||||
|
||||
List<Map> o1List = (ArrayList) o1.get("list");
|
||||
List<Map> o2pList = (ArrayList) o2.get("list");
|
||||
o1List.addAll(o2pList);
|
||||
|
||||
mergeMap.put("status", resultStatus);
|
||||
mergeMap.put("apiTotalValueSum", String.format("%.2f", apiTotalValueSum));
|
||||
mergeMap.put("apiSum", String.format("%.2f", apiSum));
|
||||
mergeMap.put("apiTotalValueSum2", String.format("%.2f", apiTotalValueSum2));
|
||||
mergeMap.put("apiSum2", String.format("%.2f", apiSum2));
|
||||
mergeMap.put("productTotalValueCountSum", String.format("%.2f", productTotalValueCountSum));
|
||||
mergeMap.put("productTotalValueAmountSum", String.format("%.2f", productTotalValueAmountSum));
|
||||
mergeMap.put("productSum", String.format("%.2f", productSum));
|
||||
mergeMap.put("productTotalValueCountSum2", String.format("%.2f", productTotalValueCountSum2));
|
||||
mergeMap.put("productTotalValueAmountSum2", String.format("%.2f", productTotalValueAmountSum2));
|
||||
mergeMap.put("productSum2", String.format("%.2f", productSum2));
|
||||
mergeMap.put("totalValueCountSum", String.format("%.2f", totalValueCountSum));
|
||||
mergeMap.put("totalValueCountSum2", String.format("%.2f", totalValueCountSum2));
|
||||
mergeMap.put("totalValueAmountSum", String.format("%.2f", totalValueAmountSum));
|
||||
mergeMap.put("totalValueAmountSum2", String.format("%.2f", totalValueAmountSum2));
|
||||
mergeMap.put("sum", String.format("%.2f", sum));
|
||||
mergeMap.put("sum2", String.format("%.2f", sum2));
|
||||
mergeMap.put("list", o1List);
|
||||
|
||||
return mergeMap;
|
||||
}
|
||||
|
||||
// ==================== Inner Classes ====================
|
||||
|
||||
/**
|
||||
* 수수료 조회 결과를 담는 내부 클래스
|
||||
*/
|
||||
private static class CommissionResult {
|
||||
private CommissionDTO data;
|
||||
private String error;
|
||||
|
||||
static CommissionResult success(CommissionDTO data) {
|
||||
CommissionResult result = new CommissionResult();
|
||||
result.data = data;
|
||||
return result;
|
||||
}
|
||||
|
||||
static CommissionResult error(String message) {
|
||||
CommissionResult result = new CommissionResult();
|
||||
result.error = message;
|
||||
return result;
|
||||
}
|
||||
|
||||
boolean hasError() {
|
||||
return error != null;
|
||||
}
|
||||
|
||||
CommissionDTO getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
String getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.commission.dto;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CommissionDTO {
|
||||
|
||||
private String status;
|
||||
private String apiTotalValueSum;
|
||||
private String apiSum;
|
||||
private String apiTotalValueSum2;
|
||||
private String apiSum2;
|
||||
private String productTotalValueCountSum;
|
||||
private String productTotalValueAmountSum;
|
||||
private String productSum;
|
||||
private String productTotalValueCountSum2;
|
||||
private String productTotalValueAmountSum2;
|
||||
private String productSum2;
|
||||
private String totalValueCountSum;
|
||||
private String totalValueCountSum2;
|
||||
private String totalValueAmountSum;
|
||||
private String totalValueAmountSum2;
|
||||
private String sum;
|
||||
private String sum2;
|
||||
private List<CommissionList> list;
|
||||
private String documentFormatNo;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.commission.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Created by jskim on 17. 01. 26.
|
||||
*
|
||||
* @author jskim
|
||||
*/
|
||||
@Data
|
||||
public class CommissionList {
|
||||
|
||||
private String paymentTargetName;
|
||||
private String paymentTargetType;
|
||||
private String value;
|
||||
private String minimumAmount;
|
||||
private String maximumAmount;
|
||||
private String totalCount;
|
||||
private String totalCount2;
|
||||
private String totalValue;
|
||||
private String totalValue2;
|
||||
private String minimumAmount2;
|
||||
private String maximumAmount2;
|
||||
private String paymentBaseName;
|
||||
private String paymentTimingName;
|
||||
private String valueDivisionName;
|
||||
private String sum;
|
||||
private String sum2;
|
||||
private String detailFeeTypeName;
|
||||
private String value2;
|
||||
private String changeReason2;
|
||||
}
|
||||