Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 094336ebf1 | |||
| a310b671e2 | |||
| d9373b00f0 | |||
| 09bc3a65f5 | |||
| 1d81681e70 | |||
| d4bafa706c | |||
| 60f9f89d90 | |||
| 8ecfaf4945 | |||
| fa736a36ab | |||
| b7869ad6e6 | |||
| 982187298c | |||
| 8118dba74d | |||
| 97489d0361 | |||
| 924c00968c | |||
| 53c3b0ee7e | |||
| b2787882bf | |||
| aafa7ac351 | |||
| b7477e10a1 | |||
| 11f1dae1f3 | |||
| 59c379b22c | |||
| d275aa12c4 | |||
| c732f1e1b2 | |||
| 9e32502ee3 | |||
| 2d0ff2bf84 | |||
| 21940a3e18 | |||
| 2e517a1e08 | |||
| 10c5e51162 | |||
| 69e5126de6 | |||
| d044ebd157 | |||
| af40f0ba61 | |||
| d6b3a44d61 | |||
| e57f2fe72f | |||
| cecf6260a6 | |||
| 52707998d4 | |||
| 28017fd83c | |||
| e55b7a6bf2 | |||
| 1b6fed7701 | |||
| 033faa6714 | |||
| 758b3ebd7d | |||
| f07123904a | |||
| 2bc2a3d6e3 | |||
| b58893d098 | |||
| 80b072983f | |||
| a456ed9bb0 | |||
| 33c64972f8 | |||
| cff0199d2d | |||
| 2e920eec96 | |||
| 65a262849e | |||
| 7056306718 | |||
| 05e0599b26 | |||
| c223fa3124 | |||
| 78b1edff5a | |||
| b41a81bda3 | |||
| 1ea2c89776 | |||
| f1d6181210 | |||
| bac9a54dee | |||
| 603cba65a7 | |||
| 889d77b990 | |||
| bf2dad6ba7 | |||
| 3a1b0cb9c1 | |||
| a13279a777 | |||
| 5299352235 | |||
| dfb37c18b9 | |||
| 5f5b23026f | |||
| 5c61fc3f24 | |||
| a7aa5bc98c | |||
| bf63701e68 | |||
| f26aae296a | |||
| 8b4bc425ec | |||
| 06dc148515 | |||
| 3cfd6b0b04 | |||
| 7a0f88bfb1 | |||
| 6d7b292b8a | |||
| 20ad6f8394 | |||
| 2bae0e350c | |||
| 45e8fa2903 | |||
| 0b6ee10439 | |||
| 1a95f015dc | |||
| 92589530d1 | |||
| be40973aa3 | |||
| 1e21b85de1 | |||
| 7b8e0575c5 | |||
| 7c0cd371f0 | |||
| eb2cce8002 | |||
| 634c97c7ca | |||
| e32acfa215 | |||
| 8356fc3029 | |||
| d6186de009 | |||
| 2a9980ab16 | |||
| 919b4a0b06 | |||
| b0ecb26a9d | |||
| 0ad5bffa4f | |||
| 766341540d | |||
| 63d4d7268c | |||
| c42a278c8e | |||
| 83352e3669 | |||
| 317b8d781d | |||
| 206e72edde | |||
| 34dce308dc | |||
| b3fc5c06bf | |||
| eb74a99a5a | |||
| 38e5a7f13c | |||
| 3b7c2e5a8f | |||
| 47099ec485 | |||
| 6dbf6af3de | |||
| 67ec6d2103 | |||
| b024f8e86f | |||
| fd16300553 | |||
| 815c329e3c | |||
| 7fdd70dbc7 | |||
| 3dd9edc849 | |||
| 6cff96f40a | |||
| a97609d533 |
@@ -83,6 +83,9 @@ file_upload/
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
|
||||
# SonarScanner 작업 디렉터리
|
||||
.scannerwork/
|
||||
|
||||
|
||||
|
||||
build_knbank.sh
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="eapim-portal [clean compileJava]" type="GradleRunConfiguration" factoryName="Gradle" nameIsGenerated="true">
|
||||
<ExternalSystemSettings>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="--no-build-cache -x test" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value="clean" />
|
||||
<option value="compileJava" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<RunAsTest>false</RunAsTest>
|
||||
<GradleProfilingDisabled>false</GradleProfilingDisabled>
|
||||
<GradleCoverageDisabled>false</GradleCoverageDisabled>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -185,8 +185,9 @@ com.eactive.apim.portal/
|
||||
|
||||
설정: `config/PortalDatasourceConfiguration.java`
|
||||
- 각 데이터베이스별 별도 EntityManager
|
||||
- Atomikos JTA를 통한 분산 트랜잭션
|
||||
- 트랜잭션 로그: `/Log/eapim/portal/`
|
||||
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
|
||||
- `transactionManager` (@Primary) → EMS
|
||||
- `gatewayTransactionManager` → Gateway
|
||||
|
||||
### 설정 프로파일
|
||||
|
||||
@@ -359,7 +360,13 @@ return queryFactory.selectFrom(user)
|
||||
적절한 propagation과 함께 `@Transactional` 사용:
|
||||
- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성)
|
||||
- 읽기 전용 작업: 최적화를 위해 `@Transactional(readOnly = true)`
|
||||
- 다중 데이터베이스: Atomikos JTA가 자동으로 분산 트랜잭션 처리
|
||||
- **다중 데이터베이스: 분산 트랜잭션(JTA/XA) 없음.** EMS·Gateway 각각 독립 로컬 트랜잭션이다.
|
||||
- 무지정 `@Transactional` = EMS(`transactionManager`)
|
||||
- **Gateway 엔티티(`com.eactive.apim.gateway.*`, `com.eactive.eai.data.entity.onl.*`)를 다루는 서비스는
|
||||
`@Transactional("gatewayTransactionManager")` 를 명시**한다. 안 하면 게이트웨이 EntityManager 가
|
||||
리포지토리 호출 단위로 닫혀 지연 로딩에서 `LazyInitializationException` 이 난다
|
||||
(예: `ApiServiceService` — `ApiGroup.apiGroupApiList`).
|
||||
- 두 DB 를 한 원자 단위로 묶어야 하는 작업은 만들지 않는다. 현재 Gateway 는 조회 전용이다.
|
||||
|
||||
### 에러 처리
|
||||
|
||||
|
||||
@@ -74,8 +74,9 @@ pipeline {
|
||||
set -eu
|
||||
cd build/libs
|
||||
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
||||
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
|
||||
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
// 보안 검사 통합 파이프라인 — OWASP Dependency-Check(SCA) + SonarQube(SAST).
|
||||
//
|
||||
// 두 검사는 보는 대상이 다르다. 하나만 돌리면 절반만 본다.
|
||||
// SonarQube : 우리가 쓴 코드의 결함 (SQL Injection, XSS, 하드코딩 시크릿 …)
|
||||
// Dependency-Check : 우리가 쓰는 라이브러리의 알려진 취약점 (CVE)
|
||||
//
|
||||
// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록만 고치면 된다)
|
||||
// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME)
|
||||
// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록 (없으면 PATH 탐색)
|
||||
// 3) 노드에 Dependency-Check CLI 설치 (DC_HOME) — 설치 방법은 17-보안 가이드 문서 참고
|
||||
// 4) NVD API Key 를 Secret text credential 로 등록 (기본 ID: nvd-api-key)
|
||||
// 키가 없으면 NVD 갱신이 극심하게 느려진다(수 시간). 폐쇄망은 UPDATE_NVD=false 로 운영.
|
||||
// 5) Quality Gate 대기를 쓰려면 SonarQube 에 <JENKINS_URL>/sonarqube-webhook/ 웹훅 등록
|
||||
//
|
||||
// JDK 분리: 빌드는 JDK 8, 스캐너/Dependency-Check 실행은 JDK 17.
|
||||
// (SonarScanner CLI 5.x 는 JRE 17 필수, Dependency-Check 9.x 이상은 JRE 11 이상 필수)
|
||||
pipeline {
|
||||
agent { label 'djb-vm' }
|
||||
|
||||
triggers {
|
||||
// 코드 변경 감지
|
||||
pollSCM('H/30 * * * *')
|
||||
// 코드가 그대로여도 새 CVE 는 계속 공개된다 → 야간 정기 재검사
|
||||
cron('H 3 * * *')
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||
}
|
||||
|
||||
parameters {
|
||||
booleanParam(
|
||||
name: 'RUN_DEPENDENCY_CHECK',
|
||||
defaultValue: true,
|
||||
description: 'OWASP Dependency-Check(SCA) 실행'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'RUN_SONAR',
|
||||
defaultValue: true,
|
||||
description: 'SonarQube 정적 분석(SAST) 실행'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'RUN_TESTS',
|
||||
defaultValue: false,
|
||||
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'UPDATE_NVD',
|
||||
defaultValue: true,
|
||||
description: 'NVD 취약점 DB 갱신. 폐쇄망(외부 인터넷 불가)이면 반드시 끈다 — 캐시된 DB 로만 검사한다.'
|
||||
)
|
||||
string(
|
||||
name: 'FAIL_ON_CVSS',
|
||||
defaultValue: '11',
|
||||
description: '이 CVSS 점수 이상이면 검사 실패로 표시. 11 = 실패시키지 않음(리포트만). 예: 7 = High 이상'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'FAIL_BUILD_ON_FINDING',
|
||||
defaultValue: false,
|
||||
description: 'FAIL_ON_CVSS 위반 시 빌드를 FAILURE 로 만든다. 끄면 UNSTABLE 로만 표시.'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'SCAN_JS',
|
||||
defaultValue: true,
|
||||
description: '정적 JS 라이브러리(static/js, static/plugins)도 RetireJS 로 검사. 폐쇄망에서 오류나면 끈다.'
|
||||
)
|
||||
}
|
||||
|
||||
environment {
|
||||
// --- Jenkins 설정 이름 (환경에 맞게 수정) ---
|
||||
SONAR_ENV_NAME = 'SonarQube-Community'
|
||||
SONAR_SCANNER_TOOL = ''
|
||||
// NVD API Key 를 담은 Secret text credential ID. 없으면 키 없이 진행한다(느림).
|
||||
NVD_API_KEY_CRED = 'nvd-api-key'
|
||||
|
||||
// --- 툴체인 ---
|
||||
JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8)
|
||||
JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner / dependency-check 실행용
|
||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/bin:${env.PATH}"
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
|
||||
// --- Dependency-Check ---
|
||||
// CLI 설치 경로. 비어 있거나 없으면 Jenkins Tools 등록분 > PATH 순으로 탐색한다.
|
||||
DC_HOME = '/apps/opts/dependency-check'
|
||||
// NVD 캐시(H2 DB). 워크스페이스 밖에 두어야 빌드마다 1GB 이상을 다시 받지 않는다.
|
||||
// 여러 Job 이 공유하므로 disableConcurrentBuilds 를 켠 채로 쓴다.
|
||||
DC_DATA = '/apps/opts/dependency-check-data'
|
||||
DC_TOOL_NAME = ''
|
||||
DC_REPORT_DIR = 'build/reports/dependency-check'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
// eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다.
|
||||
// 이 둘이 없으면 컴파일도 의존성 해석도 불가능하다.
|
||||
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
|
||||
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
|
||||
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
|
||||
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Verify toolchain') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
echo "--- build JDK ---"
|
||||
java -version
|
||||
gradle --version
|
||||
|
||||
echo "--- scanner/dependency-check JDK ---"
|
||||
if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then
|
||||
echo "JDK 17 이 없다: $JAVA_HOME_SCANNER"
|
||||
echo "SonarScanner 는 JRE 17, Dependency-Check 는 JRE 11 이상을 요구한다."
|
||||
exit 1
|
||||
fi
|
||||
"$JAVA_HOME_SCANNER/bin/java" -version
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
// build/generated 잔여 산출물이 남으면 MapStruct Impl / QueryDSL Q클래스가
|
||||
// 중복 생성되어 컴파일이 깨진다. clean 이 파일 잠금 등으로 실패하는 경우가 있어
|
||||
// 생성 소스 디렉터리는 별도로 먼저 지운다.
|
||||
stage('Compile') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
rm -rf build/generated build/classes
|
||||
gradle clean classes testClasses --no-daemon
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
when { expression { return params.RUN_TESTS } }
|
||||
steps {
|
||||
sh 'gradle test --no-daemon'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 배포본에 실리는 것과 같은 목록(runtimeClasspath)만 모은다.
|
||||
// 이게 없으면 Dependency-Check 가 스캔할 jar 가 없어 "취약점 0건" 이라는 거짓 안심을 준다.
|
||||
stage('Export dependency jars') {
|
||||
when { expression { return params.RUN_DEPENDENCY_CHECK } }
|
||||
steps {
|
||||
sh 'gradle -I ci/dependency-check-classpath.gradle exportDependencyJars --no-daemon'
|
||||
sh '''
|
||||
set -eu
|
||||
COUNT=$(find build/dependency-check/libs -name '*.jar' -type f | wc -l | tr -d ' ')
|
||||
echo "scan target jars: $COUNT"
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo "수집된 jar 가 0개다. 의존성 해석이 실패했는지 확인할 것."
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('OWASP Dependency-Check') {
|
||||
when { expression { return params.RUN_DEPENDENCY_CHECK } }
|
||||
steps {
|
||||
script {
|
||||
// CLI 위치 결정: DC_HOME > Jenkins Tools 등록분 > PATH
|
||||
def dcHome = null
|
||||
if (env.DC_HOME?.trim() && fileExists("${env.DC_HOME}/bin/dependency-check.sh")) {
|
||||
dcHome = env.DC_HOME.trim()
|
||||
echo "Dependency-Check: ${dcHome} (DC_HOME)"
|
||||
} else {
|
||||
def candidates = env.DC_TOOL_NAME?.trim()
|
||||
? [env.DC_TOOL_NAME.trim()]
|
||||
: ['dependency-check', 'Dependency-Check', 'OWASP Dependency-Check', 'dependency-check-cli']
|
||||
for (name in candidates) {
|
||||
try {
|
||||
dcHome = tool name: name,
|
||||
type: 'org.jenkinsci.plugins.DependencyCheck.tools.DependencyCheckInstallation'
|
||||
echo "Dependency-Check tool: '${name}' -> ${dcHome}"
|
||||
break
|
||||
} catch (ignored) {
|
||||
// 등록되지 않은 이름은 건너뛴다
|
||||
}
|
||||
}
|
||||
if (dcHome == null) {
|
||||
echo 'Jenkins Tools 에 등록된 Dependency-Check 가 없다. PATH 의 dependency-check.sh 로 진행한다.'
|
||||
}
|
||||
}
|
||||
|
||||
// NVD API Key credential 이 있는지 먼저 확인한다.
|
||||
// (없는 credential 로 withCredentials 를 감싸면 파이프라인 자체가 죽는다)
|
||||
def hasNvdKey = true
|
||||
try {
|
||||
withCredentials([string(credentialsId: env.NVD_API_KEY_CRED, variable: 'NVD_PROBE')]) {
|
||||
// 존재 확인만 한다
|
||||
}
|
||||
} catch (ignored) {
|
||||
hasNvdKey = false
|
||||
}
|
||||
|
||||
if (!hasNvdKey) {
|
||||
echo "NVD API Key credential('${env.NVD_API_KEY_CRED}') 이 없다. 키 없이 진행한다."
|
||||
echo 'NVD 갱신이 매우 느려진다(수 시간). https://nvd.nist.gov/developers/request-an-api-key 에서 발급 권장.'
|
||||
}
|
||||
|
||||
def dcScript = '''
|
||||
set -eu
|
||||
|
||||
# Dependency-Check 는 JRE 11 이상 필요 (빌드용 JDK 8 과 분리)
|
||||
export JAVA_HOME="$JAVA_HOME_SCANNER"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
mkdir -p "$DC_DATA" "$DC_REPORT_DIR"
|
||||
|
||||
if [ -n "${DC_RESOLVED_HOME:-}" ] && [ -x "$DC_RESOLVED_HOME/bin/dependency-check.sh" ]; then
|
||||
DC="$DC_RESOLVED_HOME/bin/dependency-check.sh"
|
||||
elif command -v dependency-check.sh >/dev/null 2>&1; then
|
||||
DC="$(command -v dependency-check.sh)"
|
||||
else
|
||||
echo "dependency-check.sh 를 찾지 못했다."
|
||||
echo "노드에 CLI 를 설치하고 DC_HOME 을 맞추거나, Manage Jenkins > Tools 에 등록할 것."
|
||||
exit 1
|
||||
fi
|
||||
echo "dependency-check: $DC"
|
||||
|
||||
ARGS=""
|
||||
|
||||
# 폐쇄망/오프라인: 캐시된 NVD DB 로만 검사한다.
|
||||
if [ "${UPDATE_NVD_FLAG}" != "true" ]; then
|
||||
ARGS="$ARGS --noupdate"
|
||||
if [ ! -d "$DC_DATA" ] || [ -z "$(ls -A "$DC_DATA" 2>/dev/null)" ]; then
|
||||
echo "NVD 캐시가 비어 있는데 갱신이 꺼져 있다: $DC_DATA"
|
||||
echo "인터넷 되는 곳에서 1회 적재한 data 디렉터리를 복사해 둘 것."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${NVD_API_KEY:-}" ]; then
|
||||
ARGS="$ARGS --nvdApiKey ${NVD_API_KEY}"
|
||||
fi
|
||||
|
||||
# OSS Index 분석기는 외부 서비스(ossindex.sonatype.org)를 호출한다.
|
||||
# 갱신을 끈 환경(=외부 통신 불가)에서는 같이 끈다.
|
||||
if [ "${UPDATE_NVD_FLAG}" != "true" ]; then
|
||||
ARGS="$ARGS --disableOssIndex"
|
||||
fi
|
||||
|
||||
# 자바 프로젝트에 불필요한 분석기 — 실행 시간만 늘린다.
|
||||
ARGS="$ARGS --disableAssembly --disableNodeAudit --disableNodeJS"
|
||||
|
||||
SCAN_ARGS="--scan build/dependency-check/libs"
|
||||
if [ "${SCAN_JS_FLAG}" = "true" ]; then
|
||||
# 번들된 JS 라이브러리(jQuery, summernote 등)의 알려진 취약점 — RetireJS
|
||||
[ -d src/main/resources/static/js ] && SCAN_ARGS="$SCAN_ARGS --scan src/main/resources/static/js"
|
||||
[ -d src/main/resources/static/plugins ] && SCAN_ARGS="$SCAN_ARGS --scan src/main/resources/static/plugins"
|
||||
else
|
||||
ARGS="$ARGS --disableRetireJS"
|
||||
fi
|
||||
|
||||
SUPPRESSION=""
|
||||
if [ -f ci/dependency-check-suppressions.xml ]; then
|
||||
SUPPRESSION="--suppression ci/dependency-check-suppressions.xml"
|
||||
fi
|
||||
|
||||
# 토큰/키가 콘솔에 남지 않도록 실행 명령 자체는 출력하지 않는다.
|
||||
set +x
|
||||
"$DC" \
|
||||
--project "DJB eAPIM Portal" \
|
||||
$SCAN_ARGS \
|
||||
--out "$DC_REPORT_DIR" \
|
||||
--format HTML --format XML --format JSON \
|
||||
--data "$DC_DATA" \
|
||||
--failOnCVSS "${FAIL_ON_CVSS_VALUE}" \
|
||||
$SUPPRESSION \
|
||||
$ARGS
|
||||
'''
|
||||
|
||||
def dcEnv = [
|
||||
"DC_RESOLVED_HOME=${dcHome ?: ''}",
|
||||
"UPDATE_NVD_FLAG=${params.UPDATE_NVD}",
|
||||
"SCAN_JS_FLAG=${params.SCAN_JS}",
|
||||
"FAIL_ON_CVSS_VALUE=${params.FAIL_ON_CVSS}"
|
||||
]
|
||||
|
||||
// 정책: 기본은 UNSTABLE 로만 표시하고 뒤의 Sonar 분석까지 마친다.
|
||||
// FAIL_BUILD_ON_FINDING 을 켜면 임계치 위반이 빌드 실패가 된다.
|
||||
def onFinding = params.FAIL_BUILD_ON_FINDING ? 'FAILURE' : 'UNSTABLE'
|
||||
catchError(buildResult: onFinding, stageResult: 'FAILURE') {
|
||||
withEnv(dcEnv) {
|
||||
if (hasNvdKey) {
|
||||
withCredentials([string(credentialsId: env.NVD_API_KEY_CRED, variable: 'NVD_API_KEY')]) {
|
||||
sh dcScript
|
||||
}
|
||||
} else {
|
||||
sh dcScript
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true,
|
||||
artifacts: 'build/reports/dependency-check/dependency-check-report.*'
|
||||
script {
|
||||
// OWASP Dependency-Check 플러그인이 설치돼 있으면 추이 그래프/경고를 남긴다.
|
||||
// 없어도 리포트는 위에서 아카이브되므로 실패로 보지 않는다.
|
||||
try {
|
||||
dependencyCheckPublisher pattern: 'build/reports/dependency-check/dependency-check-report.xml'
|
||||
} catch (ignored) {
|
||||
echo 'OWASP Dependency-Check 플러그인 미설치 — 아카이브된 HTML 리포트로 확인할 것.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sonar.java.libraries 용 클래스패스 덤프.
|
||||
// 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다.
|
||||
stage('Export analysis classpath') {
|
||||
when { expression { return params.RUN_SONAR } }
|
||||
steps {
|
||||
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||
sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('SonarQube analysis') {
|
||||
when { expression { return params.RUN_SONAR } }
|
||||
steps {
|
||||
script {
|
||||
// 스캐너 위치 결정: 지정 이름 > 흔한 등록 이름 후보 > PATH 의 sonar-scanner
|
||||
def candidates = env.SONAR_SCANNER_TOOL?.trim()
|
||||
? [env.SONAR_SCANNER_TOOL.trim()]
|
||||
: ['SonarScanner', 'SonarQube Scanner', 'sonar-scanner', 'SonarScanner CLI', 'sonarqube-scanner']
|
||||
|
||||
def scannerHome = null
|
||||
for (name in candidates) {
|
||||
try {
|
||||
scannerHome = tool name: name, type: 'hudson.plugins.sonar.SonarRunnerInstallation'
|
||||
echo "SonarScanner tool: '${name}' -> ${scannerHome}"
|
||||
break
|
||||
} catch (ignored) {
|
||||
// 등록되지 않은 이름은 건너뛴다
|
||||
}
|
||||
}
|
||||
|
||||
if (scannerHome == null) {
|
||||
echo 'Jenkins Tools 에 등록된 SonarQube Scanner 를 찾지 못했다. PATH 의 sonar-scanner 로 진행한다.'
|
||||
echo '(Manage Jenkins > Tools 에 등록한 뒤 environment 의 SONAR_SCANNER_TOOL 에 그 이름을 넣으면 확실하다.)'
|
||||
}
|
||||
|
||||
withSonarQubeEnv(env.SONAR_ENV_NAME) {
|
||||
withEnv(["SONAR_SCANNER_HOME=${scannerHome ?: ''}"]) {
|
||||
sh '''
|
||||
set -eu
|
||||
|
||||
# 스캐너는 JDK 17 로 실행한다 (빌드용 JDK 8 과 분리)
|
||||
export JAVA_HOME="$JAVA_HOME_SCANNER"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
# 토큰은 커맨드라인(-Dsonar.token)에 노출시키지 않고 환경변수로만 넘긴다.
|
||||
if [ -z "${SONAR_TOKEN:-}" ] && [ -n "${SONAR_AUTH_TOKEN:-}" ]; then
|
||||
export SONAR_TOKEN="$SONAR_AUTH_TOKEN"
|
||||
fi
|
||||
|
||||
LIBS=""
|
||||
if [ -f build/sonar/java-libraries.txt ]; then
|
||||
LIBS=$(cat build/sonar/java-libraries.txt)
|
||||
else
|
||||
echo "WARN: build/sonar/java-libraries.txt 없음 - 타입 해석 정확도 저하"
|
||||
fi
|
||||
|
||||
TEST_LIBS=""
|
||||
if [ -f build/sonar/java-test-libraries.txt ]; then
|
||||
TEST_LIBS=$(cat build/sonar/java-test-libraries.txt)
|
||||
fi
|
||||
|
||||
JUNIT_ARG=""
|
||||
if [ -d build/test-results/test ]; then
|
||||
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
|
||||
fi
|
||||
|
||||
# SonarQube 에 Dependency-Check 플러그인이 설치돼 있으면 CVE 결과도 함께 올린다.
|
||||
# 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만).
|
||||
DC_ARG=""
|
||||
if [ -f "$DC_REPORT_DIR/dependency-check-report.json" ]; then
|
||||
DC_ARG="-Dsonar.dependencyCheck.jsonReportPath=$DC_REPORT_DIR/dependency-check-report.json"
|
||||
if [ -f "$DC_REPORT_DIR/dependency-check-report.html" ]; then
|
||||
DC_ARG="$DC_ARG -Dsonar.dependencyCheck.htmlReportPath=$DC_REPORT_DIR/dependency-check-report.html"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
|
||||
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
|
||||
elif command -v sonar-scanner >/dev/null 2>&1; then
|
||||
SCANNER="$(command -v sonar-scanner)"
|
||||
else
|
||||
echo "sonar-scanner 실행 파일을 찾지 못했다."
|
||||
echo "Manage Jenkins > Tools > SonarQube Scanner 에 등록하거나 노드 PATH 에 설치할 것."
|
||||
exit 1
|
||||
fi
|
||||
echo "scanner: $SCANNER"
|
||||
|
||||
"$SCANNER" \
|
||||
-Dsonar.projectVersion="${BUILD_NUMBER}" \
|
||||
-Dsonar.java.libraries="$LIBS" \
|
||||
-Dsonar.java.test.libraries="$TEST_LIBS" \
|
||||
$JUNIT_ARG \
|
||||
$DC_ARG
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SonarQube 웹훅으로 게이트 결과를 받아 판정한다.
|
||||
// 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다.
|
||||
stage('Quality Gate') {
|
||||
when { expression { return params.RUN_SONAR } }
|
||||
steps {
|
||||
script {
|
||||
def qg = null
|
||||
try {
|
||||
timeout(time: 15, unit: 'MINUTES') {
|
||||
qg = waitForQualityGate abortPipeline: false
|
||||
}
|
||||
} catch (err) {
|
||||
unstable("Quality Gate 결과 대기 실패/타임아웃: ${err}")
|
||||
return
|
||||
}
|
||||
|
||||
if (qg == null) {
|
||||
unstable('Quality Gate 결과를 받지 못했다. SonarQube 웹훅 설정을 확인할 것.')
|
||||
} else if (qg.status != 'OK') {
|
||||
unstable("Quality Gate ${qg.status}")
|
||||
} else {
|
||||
echo 'Quality Gate OK'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: '.scannerwork/report-task.txt'
|
||||
script {
|
||||
def report = '.scannerwork/report-task.txt'
|
||||
if (fileExists(report)) {
|
||||
def url = readFile(report).readLines().find { it.startsWith('dashboardUrl=') }
|
||||
if (url) {
|
||||
echo "SonarQube: ${url.substring('dashboardUrl='.length())}"
|
||||
}
|
||||
}
|
||||
if (fileExists('build/reports/dependency-check/dependency-check-report.html')) {
|
||||
echo "Dependency-Check 리포트: ${env.BUILD_URL}artifact/build/reports/dependency-check/dependency-check-report.html"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// SonarQube 정적 보안 분석 전용 파이프라인.
|
||||
//
|
||||
// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록의 SONAR_* 만 고치면 된다)
|
||||
// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME)
|
||||
// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록
|
||||
// (SONAR_SCANNER_TOOL 이 비어 있으면 흔한 이름을 탐색하고, 없으면 PATH 의 sonar-scanner 사용)
|
||||
// 3) Quality Gate 대기를 쓰려면 SonarQube 서버에
|
||||
// Administration > Configuration > Webhooks 에 <JENKINS_URL>/sonarqube-webhook/ 등록
|
||||
// 4) Job 의 Git 설정에서 shallow clone 을 끄고 tags/history 를 받아와야 blame 기반 "새 코드" 판정이 정확하다.
|
||||
//
|
||||
// 빌드는 JDK 8, 스캐너 실행은 JDK 17 로 분리한다.
|
||||
// (SonarScanner CLI 5.x / SonarQube 10.x 이상은 JRE 17 필수. JDK 8 로 실행하면 UnsupportedClassVersionError)
|
||||
pipeline {
|
||||
agent { label 'djb-vm' }
|
||||
|
||||
triggers {
|
||||
pollSCM('H/30 * * * *')
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||
}
|
||||
|
||||
parameters {
|
||||
booleanParam(
|
||||
name: 'RUN_TESTS',
|
||||
defaultValue: false,
|
||||
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
|
||||
)
|
||||
}
|
||||
|
||||
environment {
|
||||
// --- Jenkins 설정 이름 (환경에 맞게 수정) ---
|
||||
// Manage Jenkins > System > SonarQube servers 의 Name
|
||||
SONAR_ENV_NAME = 'SonarQube-Community'
|
||||
// Manage Jenkins > Tools > SonarQube Scanner 에 등록한 이름.
|
||||
// 비워두면 아래 후보 이름들을 순서대로 탐색하고, 그래도 없으면 PATH 의 sonar-scanner 를 쓴다.
|
||||
SONAR_SCANNER_TOOL = ''
|
||||
|
||||
// --- 툴체인 ---
|
||||
JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8)
|
||||
JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner 실행용
|
||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/bin:${env.PATH}"
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
// eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다.
|
||||
// 이 둘이 없으면 컴파일 자체가 불가능하고, 컴파일 산출물이 없으면 Sonar Java 분석이 무력화된다.
|
||||
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
|
||||
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
|
||||
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
|
||||
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Verify toolchain') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
echo "--- build JDK ---"
|
||||
java -version
|
||||
gradle --version
|
||||
|
||||
echo "--- scanner JDK ---"
|
||||
if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then
|
||||
echo "JDK 17 이 없다: $JAVA_HOME_SCANNER"
|
||||
echo "SonarScanner 는 JRE 17 이상을 요구한다. 노드에 설치하거나 JAVA_HOME_SCANNER 를 수정할 것."
|
||||
exit 1
|
||||
fi
|
||||
"$JAVA_HOME_SCANNER/bin/java" -version
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
// build/generated 잔여 산출물이 남으면 MapStruct Impl / QueryDSL Q클래스가
|
||||
// 중복 생성되어 컴파일이 깨진다. clean 이 파일 잠금 등으로 실패하는 경우가 있어
|
||||
// 생성 소스 디렉터리는 별도로 먼저 지운다.
|
||||
stage('Compile') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
rm -rf build/generated build/classes
|
||||
gradle clean classes testClasses --no-daemon
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
when { expression { return params.RUN_TESTS } }
|
||||
steps {
|
||||
sh 'gradle test --no-daemon'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sonar.java.libraries 용 클래스패스 덤프.
|
||||
// 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다.
|
||||
stage('Export analysis classpath') {
|
||||
steps {
|
||||
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||
sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('SonarQube analysis') {
|
||||
steps {
|
||||
script {
|
||||
// 스캐너 위치 결정: 지정 이름 > 흔한 등록 이름 후보 > PATH 의 sonar-scanner
|
||||
def candidates = env.SONAR_SCANNER_TOOL?.trim()
|
||||
? [env.SONAR_SCANNER_TOOL.trim()]
|
||||
: ['SonarScanner', 'SonarQube Scanner', 'sonar-scanner', 'SonarScanner CLI', 'sonarqube-scanner']
|
||||
|
||||
def scannerHome = null
|
||||
for (name in candidates) {
|
||||
try {
|
||||
scannerHome = tool name: name, type: 'hudson.plugins.sonar.SonarRunnerInstallation'
|
||||
echo "SonarScanner tool: '${name}' -> ${scannerHome}"
|
||||
break
|
||||
} catch (ignored) {
|
||||
// 등록되지 않은 이름은 건너뛴다
|
||||
}
|
||||
}
|
||||
|
||||
if (scannerHome == null) {
|
||||
echo 'Jenkins Tools 에 등록된 SonarQube Scanner 를 찾지 못했다. PATH 의 sonar-scanner 로 진행한다.'
|
||||
echo '(Manage Jenkins > Tools 에 등록한 뒤 environment 의 SONAR_SCANNER_TOOL 에 그 이름을 넣으면 확실하다.)'
|
||||
}
|
||||
|
||||
withSonarQubeEnv(env.SONAR_ENV_NAME) {
|
||||
withEnv(["SONAR_SCANNER_HOME=${scannerHome ?: ''}"]) {
|
||||
sh '''
|
||||
set -eu
|
||||
|
||||
# 스캐너는 JDK 17 로 실행한다 (빌드용 JDK 8 과 분리)
|
||||
export JAVA_HOME="$JAVA_HOME_SCANNER"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
# 토큰은 커맨드라인(-Dsonar.token)에 노출시키지 않고 환경변수로만 넘긴다.
|
||||
# 플러그인 버전에 따라 SONAR_AUTH_TOKEN 만 주입되는 경우가 있다.
|
||||
if [ -z "${SONAR_TOKEN:-}" ] && [ -n "${SONAR_AUTH_TOKEN:-}" ]; then
|
||||
export SONAR_TOKEN="$SONAR_AUTH_TOKEN"
|
||||
fi
|
||||
|
||||
LIBS=""
|
||||
if [ -f build/sonar/java-libraries.txt ]; then
|
||||
LIBS=$(cat build/sonar/java-libraries.txt)
|
||||
else
|
||||
echo "WARN: build/sonar/java-libraries.txt 없음 - 타입 해석 정확도 저하"
|
||||
fi
|
||||
|
||||
TEST_LIBS=""
|
||||
if [ -f build/sonar/java-test-libraries.txt ]; then
|
||||
TEST_LIBS=$(cat build/sonar/java-test-libraries.txt)
|
||||
fi
|
||||
|
||||
JUNIT_ARG=""
|
||||
if [ -d build/test-results/test ]; then
|
||||
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
|
||||
fi
|
||||
|
||||
# Tools 에 등록된 스캐너가 없으면 PATH 에서 찾는다
|
||||
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
|
||||
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
|
||||
elif command -v sonar-scanner >/dev/null 2>&1; then
|
||||
SCANNER="$(command -v sonar-scanner)"
|
||||
else
|
||||
echo "sonar-scanner 실행 파일을 찾지 못했다."
|
||||
echo "Manage Jenkins > Tools > SonarQube Scanner 에 등록하거나 노드 PATH 에 설치할 것."
|
||||
exit 1
|
||||
fi
|
||||
echo "scanner: $SCANNER"
|
||||
|
||||
"$SCANNER" \
|
||||
-Dsonar.projectVersion="${BUILD_NUMBER}" \
|
||||
-Dsonar.java.libraries="$LIBS" \
|
||||
-Dsonar.java.test.libraries="$TEST_LIBS" \
|
||||
$JUNIT_ARG
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SonarQube 웹훅으로 게이트 결과를 받아 판정한다.
|
||||
// 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다.
|
||||
stage('Quality Gate') {
|
||||
steps {
|
||||
script {
|
||||
def qg = null
|
||||
try {
|
||||
timeout(time: 15, unit: 'MINUTES') {
|
||||
qg = waitForQualityGate abortPipeline: false
|
||||
}
|
||||
} catch (err) {
|
||||
unstable("Quality Gate 결과 대기 실패/타임아웃: ${err}")
|
||||
return
|
||||
}
|
||||
|
||||
if (qg == null) {
|
||||
unstable('Quality Gate 결과를 받지 못했다. SonarQube 웹훅 설정을 확인할 것.')
|
||||
} else if (qg.status != 'OK') {
|
||||
unstable("Quality Gate ${qg.status}")
|
||||
} else {
|
||||
echo 'Quality Gate OK'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: '.scannerwork/report-task.txt'
|
||||
script {
|
||||
def report = '.scannerwork/report-task.txt'
|
||||
if (fileExists(report)) {
|
||||
def url = readFile(report).readLines().find { it.startsWith('dashboardUrl=') }
|
||||
if (url) {
|
||||
echo "SonarQube: ${url.substring('dashboardUrl='.length())}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,8 +96,9 @@ pipeline {
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
md5sum "$f" > "$f.md5"
|
||||
done
|
||||
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
|
||||
'''
|
||||
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
|
||||
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,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,24 @@ 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"
|
||||
@@ -42,14 +60,37 @@ dependencies {
|
||||
// implementation project(':kjb-safedb')
|
||||
|
||||
implementation('org.springframework.boot:spring-boot-starter')
|
||||
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'
|
||||
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
// Thymeleaf 3.1 코어가 #temporals 를 내장 제공 → java8time extras 를 함께 두면 표현식 객체가 중복 등록된다.
|
||||
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
||||
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
||||
}
|
||||
// WW-5417 관련 public 필드 접근 권한 검사 누락 수정(OGNL #264/#265).
|
||||
// 3.3.x EOL 계열의 단기 조치. 3.4.x는 Thymeleaf 3.1.5의 OgnlContext 생성자와 비호환.
|
||||
// 일반 OGNL 경로도 ThymeleafExpressionCompatibilityTest로 검증한다(Spring EL만으로는 확인 불가).
|
||||
implementation 'ognl:ognl:3.3.5'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation('org.springframework.boot:spring-boot-starter-cache')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
@@ -57,33 +98,48 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
implementation 'org.springframework:spring-expression:5.3.30'
|
||||
// spring-expression 개별 pin 제거: 아래 ext 의 spring-framework.version 이 전 모듈을 일괄 관리한다.
|
||||
// 개별 pin 이 남아 있으면 다른 spring-* 모듈보다 낮은 버전으로 고정되어 버전이 어긋난다.
|
||||
|
||||
implementation group: 'xalan', name: 'xalan', version: '2.7.3'
|
||||
|
||||
implementation 'org.hibernate:hibernate-envers:5.6.15.Final'
|
||||
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.0.0'
|
||||
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.4.0' // 3.0.0 은 thymeleaf 3.0 전용
|
||||
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
|
||||
|
||||
implementation 'com.github.ua-parser:uap-java:1.5.3'
|
||||
// uap-java 제거: 소스 전체에 ua_parser 참조 0건이고, 1.5.3 은 snakeyaml 2.x 에서 삭제된
|
||||
// SafeConstructor() no-arg 를 호출해 NoSuchMethodError 를 낸다. 되살릴 경우 1.6.1 이상.
|
||||
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
|
||||
implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1'
|
||||
implementation 'javax.servlet:javax.servlet-api:4.0.0'
|
||||
implementation 'org.jasypt:jasypt:1.9.3'
|
||||
implementation 'xerces:xercesImpl:2.12.2'
|
||||
|
||||
implementation 'org.apache.commons:commons-lang3:3.12.0'
|
||||
// weblogic.xml prefer-application-packages 가 com.fasterxml.jackson.* 를 앱 우선으로 강제한다
|
||||
// (oracle_common jackson-databind 2.17.0 vs 앱 2.18.10 split-package 방지, WLog 실사용 확인).
|
||||
// Thymeleaf StandardJavaScriptSerializer 가 ObjectMapper.findAndRegisterModules() 로
|
||||
// jackson-module-jaxb-annotations 를 옵션 모듈로 자동탐색하는데, 이 모듈은 WebLogic 번들에만
|
||||
// 있고 앱엔 없어 ServiceConfigurationError(Provider not found) 발생 → 앱에도 명시 추가해
|
||||
// jackson 전체를 자체 완결시킨다(jackson-bom 2.18.10 로 버전 통일).
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-jaxb-annotations'
|
||||
|
||||
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
|
||||
implementation 'org.apache.commons:commons-lang3:3.20.0'
|
||||
implementation 'org.apache.commons:commons-collections4:4.4'
|
||||
|
||||
implementation 'commons-net:commons-net:3.9.0'
|
||||
implementation('commons-beanutils:commons-beanutils:1.9.4') {
|
||||
// CVE-2025-48734 (PropertyUtilsBean 이 enum 의 declaredClass 프로퍼티 노출 → ClassLoader 접근/RCE).
|
||||
// 1.11.0 부터 SuppressPropertiesBeanIntrospector 가 기본 활성이라 declaredClass 접근이 차단된다.
|
||||
// 이 앱의 호출부(PasswordMatchValidator / PasswordRuleValidator / AuthNumberValidator)는
|
||||
// 어노테이션에 박힌 고정 프로퍼티명만 넘기므로 외부 입력 경로는 없지만 버전은 올려 둔다.
|
||||
// 1.11.0 = Java 8 바이트코드(major 52), PropertyUtils.getProperty/getNestedProperty API 동일.
|
||||
implementation('commons-beanutils:commons-beanutils:1.11.0') {
|
||||
// exclude group: 'commons-collections', module: 'commons-collections'
|
||||
}
|
||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
||||
// 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'
|
||||
// jackson 개별 pin 제거: 아래 ext 의 jackson-bom.version 이 전 모듈을 일괄 관리한다.
|
||||
// 개별 pin 은 BOM 보다 우선하므로 남겨 두면 BOM 만 올렸을 때 core/annotations/databind 가
|
||||
// 옛 버전에 고정돼 버전이 어긋난다(실제로 그런 상태였다).
|
||||
|
||||
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
|
||||
|
||||
@@ -93,7 +149,10 @@ dependencies {
|
||||
implementation 'net.bytebuddy:byte-buddy:1.14.5'
|
||||
|
||||
// Commons FileUpload (WAS 독립적인 multipart 처리)
|
||||
implementation 'commons-fileupload:commons-fileupload:1.5'
|
||||
// CVE-2025-48976 (멀티파트 파트 헤더 크기 제한 부재 → DoS). 1.6.0 에서 partHeaderSizeMax 도입.
|
||||
// 주의: 1.6 부터 파트 헤더 총량 기본 상한이 10240 → 512 바이트로 줄었다(DEFAULT_PART_HEADER_SIZE_MAX).
|
||||
// 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 길어질 수 있어 실측으로 여유를 확인했다.
|
||||
implementation 'commons-fileupload:commons-fileupload:1.6.0'
|
||||
implementation 'commons-io:commons-io:2.15.1'
|
||||
|
||||
|
||||
@@ -111,6 +170,49 @@ ext {
|
||||
springMavenArtifactVersion = '5.3.30'
|
||||
encoding = 'UTF-8'
|
||||
profile = 'local'
|
||||
|
||||
// 내장 Tomcat 버전 상향 (Spring Boot 2.7.18 기본값 9.0.83 → 9.0.120).
|
||||
// Boot BOM 의 tomcat.version 프로퍼티를 덮어써서 tomcat-embed-core/-el/-websocket 이 함께 올라간다.
|
||||
// 9.0.x 계열 유지 = Servlet 4.0 / javax.* 네임스페이스 그대로, JDK8 호환.
|
||||
set('tomcat.version', '9.0.120')
|
||||
|
||||
// CVE-2022-1471 (snakeyaml Constructor 임의 타입 역직렬화 → RCE). Boot 2.7.18 BOM 기본값 1.30 → 2.6.
|
||||
// 앱/프레임워크 실경로는 이미 SafeConstructor 계열(OriginTrackedYamlLoader, spring-beans
|
||||
// FilteringConstructor, swagger-parser DeserializationUtils)이라 익스플로잇 경로는 없었으나
|
||||
// SCA 는 버전으로 판정하므로 2.x 로 올린다. snakeyaml 2.6 = Java 8 바이트코드(major 52).
|
||||
// 2.6 인 이유: swagger-core 2.2.52 / swagger-parser-v3 2.1.45 가 요구하는 버전이라 강등이 없다.
|
||||
// 부수 효과: swagger-parser 가 호출하는 LoaderOptions.setCodePointLimit(1.32+ API) 도 해소.
|
||||
set('snakeyaml.version', '2.6')
|
||||
|
||||
// jackson 전 모듈 버전 통일(Boot 2.7.18 BOM 기본 2.13.5). 2.18.x 는 JDK8 호환 라인이다.
|
||||
// 이유 3가지
|
||||
// 1) snakeyaml 2.x 는 ParserImpl(StreamReader) 를 제거했고 jackson-dataformat-yaml 은
|
||||
// 2.15+ 부터 ParserImpl(StreamReader, LoaderOptions) 를 쓴다 — 위 snakeyaml 상향의 전제.
|
||||
// 2) WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정.
|
||||
// 3) jackson-databind PolymorphicTypeValidator 우회(제네릭 타입 인자 미검증) — 2.18.8 에서 수정.
|
||||
// 이 앱은 다형성 역직렬화(activateDefaultTyping/@JsonTypeInfo)를 쓰지 않아 노출 경로는 없다.
|
||||
// 2.18.x 마지막 패치를 쓴다.
|
||||
set('jackson-bom.version', '2.18.10')
|
||||
|
||||
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
|
||||
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
|
||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
|
||||
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
|
||||
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
|
||||
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
||||
set('thymeleaf.version', '3.1.5.RELEASE')
|
||||
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
|
||||
|
||||
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
||||
// 5.3.32~5.3.39 구간 CVE 정리용. 5.3.x 는 OSS EOL 이라 이 위로는 상용(Enterprise) 빌드뿐이다.
|
||||
// 남는 5.3.x 지적(CVE-2026-41855 JMS Jackson 역직렬화, CVE-2016-1000027 HttpInvoker)은
|
||||
// 5.3 계열에 수정본이 없고 앱이 JMS/HttpInvoker 를 쓰지 않으므로 억제 항목으로 따로 관리한다.
|
||||
set('spring-framework.version', '5.3.39')
|
||||
|
||||
// Spring Security 5.7.x OSS 마지막 릴리스(Boot 2.7.18 BOM 기본 5.7.11).
|
||||
// CVE-2026-22732(응답 커밋 후 보안 헤더 미기록)는 수정본이 5.7.22/5.8.24 = Enterprise 전용이라
|
||||
// OSS 로는 올릴 수 없다. 우회책(HeaderWriterFilter.shouldWriteHeadersEagerly=true)은 동작 변경이라 분리 검토.
|
||||
set('spring-security.version', '5.7.14')
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +231,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]
|
||||
}
|
||||
|
||||
|
||||
@@ -181,9 +292,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 {
|
||||
@@ -192,8 +315,22 @@ 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)
|
||||
}
|
||||
|
||||
// 정적 리소스만 별도 zip (OHS 추가 배포용). war/bootWar 는 그대로 정적 리소스 포함 유지
|
||||
// (WAS 단독 접속 인원 지원). sass/ 는 컴파일 소스이며 실제 서빙 경로에 없어 제외
|
||||
// (PortalConfigWebDispatcherServlet#addResourceHandlers 기준).
|
||||
task staticResourcesZip(type: Zip) {
|
||||
archiveFileName = "eapim-portal-static.zip"
|
||||
destinationDirectory = file("$buildDir/libs")
|
||||
from('src/main/resources/static') {
|
||||
exclude 'sass/**'
|
||||
}
|
||||
}
|
||||
assemble.dependsOn staticResourcesZip
|
||||
|
||||
task printSourceSets {
|
||||
doLast {
|
||||
sourceSets.each { srcSet ->
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// OWASP Dependency-Check 가 스캔할 서드파티 jar 를 한 디렉터리로 모은다.
|
||||
//
|
||||
// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다.
|
||||
// gradle -I ci/dependency-check-classpath.gradle exportDependencyJars --no-daemon
|
||||
//
|
||||
// 출력:
|
||||
// build/dependency-check/libs/*.jar runtimeClasspath 의 외부 의존 jar (원본 파일명 유지)
|
||||
// build/dependency-check/jars.txt 수집 목록(경로 1줄씩) — 진단용
|
||||
//
|
||||
// 왜 디렉터리로 모으는가
|
||||
// Dependency-Check CLI 는 --scan <경로> 로 파일/디렉터리를 받는다. Gradle 캐시를 통째로 스캔하면
|
||||
// 이 프로젝트가 쓰지 않는 버전까지 잡히고, 프로젝트 디렉터리만 스캔하면 의존 jar 가 아예 안 잡힌다.
|
||||
// 실제 배포본에 실리는 것과 같은 목록(runtimeClasspath)만 모아야 결과가 배포 산출물과 일치한다.
|
||||
//
|
||||
// 파일명을 유지해야 하는 이유
|
||||
// Dependency-Check 는 jar 안의 POM/MANIFEST 외에 파일명에서도 CPE(제품/버전)를 추론한다.
|
||||
// 이름을 바꾸면 탐지 정확도가 떨어진다.
|
||||
|
||||
rootProject { project ->
|
||||
project.plugins.withId('java') {
|
||||
project.tasks.register('exportDependencyJars') {
|
||||
group = 'verification'
|
||||
description = 'Dependency-Check 스캔용 런타임 의존 jar 를 build/dependency-check/libs 에 모은다'
|
||||
|
||||
doLast {
|
||||
def outRoot = new File(project.layout.buildDirectory.get().asFile, 'dependency-check')
|
||||
def outDir = new File(outRoot, 'libs')
|
||||
project.delete(outDir)
|
||||
outDir.mkdirs()
|
||||
|
||||
// 컴포지트/서브프로젝트의 build 디렉터리 산출물(우리가 만든 jar)은 제외한다.
|
||||
// 자체 코드는 SonarQube 가 보는 영역이고, SCA 대상은 외부 라이브러리다.
|
||||
def buildDirs = project.allprojects.collect {
|
||||
it.layout.buildDirectory.get().asFile.absolutePath
|
||||
}
|
||||
def isOwnArtifact = { File f ->
|
||||
buildDirs.any { f.absolutePath.startsWith(it + File.separator) }
|
||||
}
|
||||
|
||||
def jars = project.configurations.runtimeClasspath.files
|
||||
.findAll { it.isFile() && it.name.endsWith('.jar') && !isOwnArtifact(it) }
|
||||
.unique()
|
||||
.sort { it.name }
|
||||
|
||||
project.copy {
|
||||
from jars
|
||||
into outDir
|
||||
}
|
||||
|
||||
new File(outRoot, 'jars.txt').text =
|
||||
jars.collect { it.absolutePath }.join(System.lineSeparator()) + System.lineSeparator()
|
||||
|
||||
def totalMb = (jars.sum { it.length() } ?: 0L) / (1024 * 1024)
|
||||
logger.lifecycle("dependency-check scan target: ${jars.size()} jars, ${totalMb as int} MB -> ${outDir}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
OWASP Dependency-Check 오탐(false positive) 억제 목록.
|
||||
|
||||
규칙
|
||||
1. 억제는 "오탐"에만 쓴다. 진짜 취약점을 조용히 숨기는 용도로 쓰지 않는다.
|
||||
실제 취약하지만 당장 못 올리는 경우는 억제 대신 만료일(until)을 넣어 재검토를 강제한다.
|
||||
2. 항목마다 <notes> 에 판단 근거와 판단자/일자를 남긴다. 근거 없는 억제는 리뷰에서 거절한다.
|
||||
3. 범위를 좁게 잡는다. cve 단건 + 특정 파일(sha1/packageUrl)이 기본이고,
|
||||
cpe 나 정규식 filePath 로 넓게 억제하지 않는다.
|
||||
|
||||
작성법
|
||||
Dependency-Check HTML 리포트의 각 취약점 옆 "Suppress" 버튼을 누르면 해당 항목의
|
||||
<suppress> 블록이 그대로 생성된다. 그것을 이 파일에 붙여넣고 <notes> 만 채우면 된다.
|
||||
|
||||
참고: https://dependency-check.github.io/DependencyCheck/general/suppression.html
|
||||
-->
|
||||
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
|
||||
|
||||
<!-- 예시 (실제 억제 시 주석을 풀고 값 교체)
|
||||
<suppress until="2026-12-31Z">
|
||||
<notes><![CDATA[
|
||||
오탐 근거: 해당 CVE 는 X 기능을 사용할 때만 성립하는데 이 앱은 해당 API 를 호출하지 않음.
|
||||
확인: 홍길동 / 2026-08-13 / 호출부 grep 결과 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.example/example-lib@.*$</packageUrl>
|
||||
<cve>CVE-2026-00000</cve>
|
||||
</suppress>
|
||||
-->
|
||||
|
||||
<!-- ================================================================================
|
||||
Spring 계열 Critical 5건 (2026-08-18 리포트 기준).
|
||||
|
||||
공통 배경: Spring Framework 5.3.x / Spring Boot 2.7.x / Spring Security 5.7.x 는 모두 OSS EOL 이라
|
||||
남은 수정본이 상용(Enterprise/Tanzu)뿐이다. JDK 8 유지 제약상 Boot 3 + Spring 6 이관 전까지는
|
||||
버전 상향으로 못 없앤다. 아래는 "코드에 트리거 경로가 없음"을 근거로 한 억제이며 만료일을 둔다.
|
||||
버전 범위를 5.3.x / 2.7.x / 5.7.x 로 고정해 두었으므로, 이관 후에는 억제가 자동으로 풀린다.
|
||||
재검토 시 확인할 것: (1) 각 근거 grep 이 여전히 0건인지 (2) OSS 수정본이 나왔는지.
|
||||
================================================================================ -->
|
||||
|
||||
<!-- 1. Spring Boot: Cassandra SSL 호스트명 미검증 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-40974 는 Spring Boot 의 Cassandra SSL 자동설정이 SSL 번들의 호스트명 검증 설정을
|
||||
드라이버에 전달하지 않는 문제다. 이 앱은 Cassandra 를 쓰지 않는다
|
||||
(runtimeClasspath 에 cassandra/datastax 계열 jar 0건, spring-boot-starter-data-cassandra 미선언).
|
||||
수정본 2.7.33 은 상용(Enterprise) 릴리스라 OSS 로는 올릴 수 없다.
|
||||
devtools/actuator/spring-boot-admin jar 는 스캔 대상(runtimeClasspath)에는 있으나
|
||||
배포 산출물에서는 build.gradle 의 localOnlyLibPrefixes 로 제외되어 WAR 에 실리지 않는다.
|
||||
확인: Rinjae / 2026-08-18 / grep -ri cassandra 0건, WAR 내 devtools/actuator jar 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
|
||||
<cve>CVE-2026-40974</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 2. Thymeleaf SSTI 2건: starter jar 이름 기준 오탐 -->
|
||||
<suppress>
|
||||
<notes><![CDATA[
|
||||
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
|
||||
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
|
||||
thymeleaf.version=3.1.4.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
|
||||
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.4.RELEASE.jar 다.
|
||||
Dependency-Check 가 spring-boot-starter-thymeleaf-2.7.18.jar(의존만 선언한 빈 starter)에
|
||||
thymeleaf CPE 를 잘못 매칭한 결과다. thymeleaf 본체 jar 에 대한 탐지는 억제하지 않는다.
|
||||
확인: Rinjae / 2026-08-18 / WAR 내 thymeleaf-3.1.4.RELEASE.jar, thymeleaf-spring5-3.1.4.RELEASE.jar.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$</packageUrl>
|
||||
<cve>CVE-2026-40477</cve>
|
||||
<cve>CVE-2026-40478</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 3. Spring Framework: JMS Jackson 역직렬화 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-41855 는 org.springframework.jms.support.converter.MappingJackson2MessageConverter /
|
||||
JacksonJsonMessageConverter 가 임의 클래스 인스턴스화를 허용하는 문제다(신뢰할 수 없는 JMS 환경 전제).
|
||||
이 앱은 JMS 를 쓰지 않는다: runtimeClasspath 에 spring-jms 0건, 소스에 javax.jms /
|
||||
JmsTemplate / MappingJackson2MessageConverter 참조 0건.
|
||||
5.3.x 는 OSS 수정본이 없다(6.2.19 / 7.0.8 에서만 수정). 이미 5.3.x OSS 마지막인 5.3.39 로 올려 둔 상태다.
|
||||
확인: Rinjae / 2026-08-18 / grep -rn "javax.jms|JmsTemplate|MappingJackson2MessageConverter" src 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-.*@5\.3\..*$</packageUrl>
|
||||
<cve>CVE-2026-41855</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 4. spring-web: HttpInvoker 역직렬화 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2016-1000027 은 HttpInvokerServiceExporter 를 노출했을 때만 성립한다. 해당 클래스는
|
||||
Spring 6.0 에서 제거됐고 5.3.x 에는 수정본이 없다(= 5.3.x 를 쓰는 한 계속 탐지된다).
|
||||
이 앱은 HttpInvoker 계열을 쓰지 않는다: 소스에 HttpInvoker 참조 0건이고 외부 호출은
|
||||
RestTemplate / HttpClient 기반이다.
|
||||
근본 해결은 Spring 6(Boot 3, JDK 17) 이관. 이관 전까지 억제한다.
|
||||
확인: Rinjae / 2026-08-18 / grep -rn HttpInvoker src 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-web@5\.3\..*$</packageUrl>
|
||||
<cve>CVE-2016-1000027</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 5. Spring Security: 응답 커밋 시 보안 헤더 미기록 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-22732 는 응답이 커밋된 뒤 Spring Security 가 보안 헤더를 기록하지 못하는 문제다.
|
||||
트리거는 Content-Length 를 setHeader / setIntHeader / addIntHeader 로 지정하는 경로다
|
||||
(OnCommittedResponseWrapper 는 setContentLength / setContentLengthLong / addHeader 만 추적한다).
|
||||
(1) 이 코드베이스에는 트리거가 없다: portal / elink-portal-common / elink-online-core-jpa 전체에
|
||||
setIntHeader, addIntHeader, setHeader("Content-Length") 0건. 파일 다운로드는
|
||||
response.setContentLength(int) 를 쓰며 실측상 보안 헤더가 정상 기록된다.
|
||||
(2) 그럼에도 안전망으로 PortalConfigSecurity 에서
|
||||
HeaderWriterFilter.setShouldWriteHeadersEagerly(true) 를 적용해(spring.io 권고 우회책)
|
||||
요청 시작 시점에 헤더를 기록하도록 했다.
|
||||
수정본 5.7.22 / 5.8.24 는 Enterprise 전용이라 OSS 로는 올릴 수 없다. 이미 5.7.x OSS 마지막인
|
||||
5.7.14 로 올려 둔 상태다.
|
||||
확인: Rinjae / 2026-08-18 / 동일 스택(Boot 2.7.18 + Security 5.7.14 + Tomcat 9.0.120) 프로브 앱 실측 —
|
||||
우회책 미적용 시 setHeader/setIntHeader/addIntHeader 경로에서 헤더 누락 재현, 적용 후 정상 기록.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.security/spring-security-.*@5\.7\..*$</packageUrl>
|
||||
<cve>CVE-2026-22732</cve>
|
||||
</suppress>
|
||||
|
||||
</suppressions>
|
||||
@@ -0,0 +1,41 @@
|
||||
// SonarScanner 의 sonar.java.libraries / sonar.java.test.libraries 로 넘길
|
||||
// 컴파일 클래스패스를 파일로 덤프한다.
|
||||
//
|
||||
// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다.
|
||||
// gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon
|
||||
//
|
||||
// 출력(콤마 구분, 절대경로):
|
||||
// build/sonar/java-libraries.txt main 컴파일 클래스패스
|
||||
// build/sonar/java-test-libraries.txt main + test 컴파일 클래스패스
|
||||
//
|
||||
// 이 파일이 없으면 Sonar Java 분석기가 타입을 해석하지 못해
|
||||
// 보안 룰(SQL Injection, XSS 등) 상당수가 침묵한다. 선택 사항이 아니다.
|
||||
|
||||
rootProject { project ->
|
||||
project.plugins.withId('java') {
|
||||
project.tasks.register('exportSonarClasspath') {
|
||||
group = 'verification'
|
||||
description = 'SonarScanner 용 컴파일 클래스패스를 build/sonar 에 덤프한다'
|
||||
|
||||
doLast {
|
||||
def outDir = new File(project.layout.buildDirectory.get().asFile, 'sonar')
|
||||
outDir.mkdirs()
|
||||
|
||||
def toLine = { files ->
|
||||
files.findAll { it.exists() }
|
||||
.collect { it.absolutePath }
|
||||
.unique()
|
||||
.join(',')
|
||||
}
|
||||
|
||||
def mainCp = project.sourceSets.main.compileClasspath.files
|
||||
def testCp = project.sourceSets.test.compileClasspath.files
|
||||
|
||||
new File(outDir, 'java-libraries.txt').text = toLine(mainCp)
|
||||
new File(outDir, 'java-test-libraries.txt').text = toLine(mainCp + testCp)
|
||||
|
||||
logger.lifecycle("sonar classpath dumped: ${outDir}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
## OHS Static Resource 설정 예시
|
||||
|
||||
```apacheconf
|
||||
<VirtualHost *:443>
|
||||
ServerName weblogic-djb.rinjae.kr
|
||||
|
||||
<IfModule ossl_module>
|
||||
SSLEngine on
|
||||
SSLVerifyClient None
|
||||
SSLProtocol TLSv1.2 TLSv1.3
|
||||
SSLHonorCipherOrder on
|
||||
SSLCipherSuite TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
SSLWallet "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/keystores/default"
|
||||
|
||||
<FilesMatch "\.(cgi|shtml|phtml|php)$">
|
||||
SSLOptions +StdEnvVars
|
||||
</FilesMatch>
|
||||
|
||||
<Directory "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/cgi-bin">
|
||||
SSLOptions +StdEnvVars
|
||||
</Directory>
|
||||
|
||||
BrowserMatch "MSIE [2-5]" \
|
||||
nokeepalive ssl-unclean-shutdown \
|
||||
downgrade-1.0 force-response-1.0
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set Strict-Transport-Security "max-age=63072000; preload; includeSubDomains"
|
||||
</IfModule>
|
||||
</IfModule>
|
||||
|
||||
Alias /css /apps/portal-static/css
|
||||
Alias /js /apps/portal-static/js
|
||||
Alias /img /apps/portal-static/img
|
||||
Alias /images /apps/portal-static/images
|
||||
Alias /webfonts /apps/portal-static/webfonts
|
||||
Alias /font /apps/portal-static/font
|
||||
Alias /html /apps/portal-static/html
|
||||
Alias /plugins /apps/portal-static/plugins
|
||||
Alias /favicon.png /apps/portal-static/favicon.png
|
||||
|
||||
<Directory "/apps/portal-static">
|
||||
Require all granted
|
||||
Options -Indexes
|
||||
</Directory>
|
||||
|
||||
<LocationMatch "^/favicon\.png$|^/(css|js|img|images|webfonts|font|html|plugins)/">
|
||||
Header set Cache-Control "no-cache"
|
||||
</LocationMatch>
|
||||
|
||||
<IfModule weblogic_module>
|
||||
<Location />
|
||||
DirectoryIndex disabled
|
||||
SetHandler weblogic-handler
|
||||
WebLogicHost 172.30.1.100
|
||||
WebLogicPort 39130
|
||||
DynamicServerList OFF
|
||||
ConnectTimeoutSecs 10
|
||||
ConnectRetrySecs 2
|
||||
WLProxySSL ON
|
||||
</Location>
|
||||
</IfModule>
|
||||
|
||||
<Location /css>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /js>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /img>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /images>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /webfonts>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /font>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /html>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /plugins>
|
||||
SetHandler None
|
||||
</Location>
|
||||
<Location /favicon.png>
|
||||
SetHandler None
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
```
|
||||
@@ -0,0 +1,343 @@
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import http.cookiejar
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
DEFAULT_HOST = 'https://api.jejubank.co.kr'
|
||||
COOKIE_NAME = 'JSESSIONID_PORTAL'
|
||||
USER_AGENT = (
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/140.0.0.0 Safari/537.36'
|
||||
)
|
||||
COLUMNS = (
|
||||
('회', 3),
|
||||
('대상', 4),
|
||||
('결과', 10),
|
||||
('쿠키', 8),
|
||||
('CSRF', 8),
|
||||
('HTTP', 4),
|
||||
('요청 값', 10),
|
||||
('요청 서버ID', 11),
|
||||
('응답 값', 10),
|
||||
('응답 서버ID', 11),
|
||||
)
|
||||
BORDER = '+' + '+'.join('-' * (width + 2) for _, width in COLUMNS) + '+'
|
||||
|
||||
|
||||
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, request, fp, code, message, headers, new_url):
|
||||
# 두 서버 비교에서는 지정한 주소의 첫 응답만 검사한다.
|
||||
# 공유 세션 쿠키를 리다이렉트 대상에 전달하지 않는다.
|
||||
return None
|
||||
|
||||
|
||||
def integer_between(minimum, maximum):
|
||||
def parse(value):
|
||||
try:
|
||||
number = int(value)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||
)
|
||||
if not minimum <= number <= maximum:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||
)
|
||||
return number
|
||||
return parse
|
||||
|
||||
|
||||
def host_address(value):
|
||||
value = value.strip()
|
||||
message = 'http://호스트[:포트] 또는 https://호스트[:포트] 형식으로 입력하세요.'
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
parsed.port # 포트 형식과 범위도 검사한다.
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(message)
|
||||
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
|
||||
or parsed.path not in ('', '/') or parsed.query or parsed.fragment
|
||||
or parsed.username is not None or parsed.password is not None
|
||||
or any(char.isspace() for char in value)):
|
||||
raise argparse.ArgumentTypeError(message)
|
||||
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description='새 익명 세션으로 쿠키와 CSRF 토큰의 유지 여부를 표로 확인합니다.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--host', type=host_address, default=DEFAULT_HOST,
|
||||
metavar='주소', help=f'대상 호스트 주소 (기본값: {DEFAULT_HOST})',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--host2', type=host_address, metavar='주소',
|
||||
help='두 번째 서버. A/B를 번갈아 요청 (리다이렉트 미추적)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--cookie-mode', choices=('shared', 'separate'), default='shared',
|
||||
help='shared: 직전 세션 쿠키 공유, separate: 서버 주소/포트별 저장소 분리 (기본값: shared)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n', '--count', type=integer_between(1, 100), default=10,
|
||||
metavar='횟수', help='전체 요청 횟수: 1~100회 (기본값: 10회, 두 서버 모드도 합산)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-d', '--delay', '--delay-ms', type=integer_between(0, 10000), default=50,
|
||||
metavar='밀리초', help='요청 사이 대기시간: 0~10000ms (기본값: 50ms)',
|
||||
)
|
||||
tls_options = parser.add_mutually_exclusive_group()
|
||||
tls_options.add_argument(
|
||||
'--cacert', metavar='CA파일',
|
||||
help='신뢰할 사설 CA 인증서 또는 인증서 묶음 파일 (PEM 형식)',
|
||||
)
|
||||
tls_options.add_argument(
|
||||
'-k', '--insecure', action='store_true',
|
||||
help='HTTPS 서버 인증서와 호스트명 검증 생략',
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def tls_context(args):
|
||||
context = ssl.create_default_context()
|
||||
if args.cacert:
|
||||
context.load_verify_locations(cafile=args.cacert)
|
||||
if args.insecure:
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return context
|
||||
|
||||
|
||||
def cookie_from_headers(headers):
|
||||
value = None
|
||||
for header in headers:
|
||||
parsed = SimpleCookie()
|
||||
parsed.load(header)
|
||||
if COOKIE_NAME in parsed:
|
||||
value = parsed[COOKIE_NAME].value
|
||||
return value
|
||||
|
||||
|
||||
def cookie_for_url(jar, url):
|
||||
request = urllib.request.Request(url)
|
||||
jar.add_cookie_header(request)
|
||||
return cookie_from_headers([request.get_header('Cookie', '')])
|
||||
|
||||
|
||||
def host_key(url):
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
port = parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)
|
||||
return parsed.scheme, parsed.hostname, port
|
||||
|
||||
|
||||
def carry_session_cookie(request, value):
|
||||
# 현재 호스트에 해당하는 다른 쿠키는 유지하고 포털 세션 쿠키만 이어 보낸다.
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(request.get_header('Cookie', ''))
|
||||
cookies[COOKIE_NAME] = value
|
||||
request.add_unredirected_header(
|
||||
'Cookie', cookies.output(header='', sep='; ').strip()
|
||||
)
|
||||
|
||||
|
||||
def server_id(cookie):
|
||||
return cookie.split('!')[1] if cookie and '!' in cookie else '-'
|
||||
|
||||
|
||||
def cell(value, width):
|
||||
text = str(value)
|
||||
# 한글은 보통 터미널에서 두 칸을 차지하므로 표시 폭을 기준으로 정렬한다.
|
||||
display_width = sum(
|
||||
0 if unicodedata.combining(char) else
|
||||
2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1
|
||||
for char in text
|
||||
)
|
||||
return text + ' ' * max(0, width - display_width)
|
||||
|
||||
|
||||
def print_row(values):
|
||||
print('| ' + ' | '.join(
|
||||
cell(value, width) for value, (_, width) in zip(values, COLUMNS)
|
||||
) + ' |', flush=True)
|
||||
|
||||
|
||||
def change_state(previous, current):
|
||||
if previous is None:
|
||||
return '최초'
|
||||
return '유지' if previous == current else '!!변경!!'
|
||||
|
||||
|
||||
def run_probe(args):
|
||||
targets = [('A', args.host + '/api/session/csrf')]
|
||||
if args.host2:
|
||||
targets.append(('B', args.host2 + '/api/session/csrf'))
|
||||
try:
|
||||
context = tls_context(args)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f'TLS 설정 실패: {error}', file=sys.stderr)
|
||||
return 1
|
||||
jar = http.cookiejar.CookieJar()
|
||||
cookie_processor = urllib.request.HTTPCookieProcessor(jar)
|
||||
handlers = [
|
||||
urllib.request.HTTPSHandler(context=context),
|
||||
cookie_processor,
|
||||
]
|
||||
if args.host2:
|
||||
handlers.append(NoRedirectHandler())
|
||||
client = urllib.request.build_opener(*handlers)
|
||||
previous_cookie = None
|
||||
previous_token = None
|
||||
cookie_stores = {}
|
||||
session_states = {}
|
||||
totals = {'최초': 0, '유지': 0, '변경': 0}
|
||||
completed = 0
|
||||
failure = None
|
||||
exit_code = 0
|
||||
|
||||
for target, url in targets:
|
||||
print(f'대상 {target}: {url}')
|
||||
if len(targets) == 2:
|
||||
print('호출 순서: A -> B -> A -> B ... (클라이언트 1개, 전체 요청 횟수 기준)')
|
||||
print('두 서버 비교에서는 3xx 리다이렉트를 따라가지 않습니다.')
|
||||
if args.cookie_mode == 'separate':
|
||||
print('쿠키 모드: separate | 서버 주소/포트별 저장소 및 변경 비교 기준 분리')
|
||||
elif len(targets) == 2:
|
||||
print(f'쿠키 모드: shared | 직전 {COOKIE_NAME}을 다음 서버로 전달')
|
||||
if any(url.startswith('https://') for _, url in targets):
|
||||
tls_mode = ('생략 (--insecure)' if args.insecure else
|
||||
f'사설 CA 추가 ({args.cacert})' if args.cacert else '기본 CA 사용')
|
||||
print(f'TLS 인증서 검증: {tls_mode}')
|
||||
print(f'쿠키 키: {COOKIE_NAME} | 값: 앞 10자리 | 횟수: {args.count}회 | 딜레이: {args.delay}ms')
|
||||
print(BORDER)
|
||||
print_row([title for title, _ in COLUMNS])
|
||||
print(BORDER, flush=True)
|
||||
|
||||
try:
|
||||
for number in range(1, args.count + 1):
|
||||
if number > 1 and args.delay:
|
||||
time.sleep(args.delay / 1000)
|
||||
|
||||
target, url = targets[(number - 1) % len(targets)]
|
||||
if args.cookie_mode == 'separate':
|
||||
key = host_key(url)
|
||||
if key not in cookie_stores:
|
||||
cookie_stores[key] = http.cookiejar.CookieJar()
|
||||
jar = cookie_stores[key]
|
||||
# 순차 요청마다 같은 클라이언트의 쿠키 저장소만 교체한다.
|
||||
cookie_processor.cookiejar = jar
|
||||
previous_cookie, previous_token = session_states.get(key, (None, None))
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
'Accept': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
)
|
||||
# 응답이 저장소를 갱신하기 전에 실제 전송할 쿠키를 기록한다.
|
||||
jar.add_cookie_header(request)
|
||||
if args.host2 and args.cookie_mode == 'shared' and previous_cookie is not None:
|
||||
carry_session_cookie(request, previous_cookie)
|
||||
request_cookie = cookie_from_headers([request.get_header('Cookie', '')])
|
||||
response_cookie = None
|
||||
status = '-'
|
||||
try:
|
||||
with client.open(request, timeout=10) as response:
|
||||
status = response.status
|
||||
response_url = response.geturl()
|
||||
# 이번 응답의 Set-Cookie만 표시한다. 재발급이 없으면 '없음'.
|
||||
response_cookie = cookie_from_headers(
|
||||
response.headers.get_all('Set-Cookie') or []
|
||||
)
|
||||
data = json.load(response)
|
||||
|
||||
if args.host2 and args.cookie_mode == 'shared' and response_cookie is None:
|
||||
# 다른 호스트에 직접 이어 보낸 쿠키는 저장소에 없을 수 있다.
|
||||
# 재발급이 없으면 이번 요청에 실었던 세션을 계속 사용한다.
|
||||
cookie = request_cookie
|
||||
else:
|
||||
cookie = cookie_for_url(jar, response_url)
|
||||
token = data.get('token') if isinstance(data, dict) else None
|
||||
if not cookie or not token:
|
||||
raise ValueError('세션 쿠키 또는 CSRF 토큰 없음')
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
response_cookie = cookie_from_headers(
|
||||
error.headers.get_all('Set-Cookie') or []
|
||||
)
|
||||
failure = f'{number}회: HTTP {status}'
|
||||
error.close()
|
||||
except Exception as error:
|
||||
failure = f'{number}회: {error}'
|
||||
|
||||
if failure:
|
||||
result, cookie_state, token_state = '!!실패!!', '-', '-'
|
||||
exit_code = 1
|
||||
else:
|
||||
# 변경 판정은 앞 10자리가 아니라 전체 쿠키와 전체 토큰으로 비교한다.
|
||||
cookie_state = change_state(previous_cookie, cookie)
|
||||
token_state = change_state(previous_token, token)
|
||||
if '!!변경!!' in (cookie_state, token_state):
|
||||
outcome, result = '변경', '>>>변경<<<'
|
||||
elif previous_cookie is None:
|
||||
outcome, result = '최초', '[최초]'
|
||||
else:
|
||||
outcome, result = '유지', '[유지]'
|
||||
totals[outcome] += 1
|
||||
completed += 1
|
||||
previous_cookie, previous_token = cookie, token
|
||||
if args.cookie_mode == 'separate':
|
||||
session_states[host_key(url)] = (cookie, token)
|
||||
|
||||
print_row((
|
||||
number, target, result, cookie_state, token_state, status,
|
||||
request_cookie[:10] if request_cookie else '없음',
|
||||
server_id(request_cookie),
|
||||
response_cookie[:10] if response_cookie else '없음',
|
||||
server_id(response_cookie),
|
||||
))
|
||||
if failure:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
failure = '사용자가 중단했습니다.'
|
||||
exit_code = 130
|
||||
|
||||
print(BORDER)
|
||||
print(f'정상 조회: {completed}/{args.count}회 | 최초: {totals["최초"]}회 | 유지: {totals["유지"]}회 | 변경: {totals["변경"]}회')
|
||||
print('응답 값=없음: Set-Cookie 재발급 없음. 서버ID는 각 쿠키의 ! 뒤 식별값입니다.')
|
||||
if failure:
|
||||
print(f'실패/중단: {failure}')
|
||||
return exit_code
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
started_counter = time.perf_counter()
|
||||
started_at = datetime.now().astimezone()
|
||||
print(f'시작 시각: {started_at.isoformat(sep=" ", timespec="milliseconds")}', flush=True)
|
||||
try:
|
||||
return run_probe(args)
|
||||
except KeyboardInterrupt:
|
||||
print('실패/중단: 사용자가 중단했습니다.')
|
||||
return 130
|
||||
finally:
|
||||
finished_at = datetime.now().astimezone()
|
||||
# 시스템 시각 보정에 영향받지 않도록 경과 시간은 별도 시계로 측정한다.
|
||||
elapsed = time.perf_counter() - started_counter
|
||||
print(f'종료 시각: {finished_at.isoformat(sep=" ", timespec="milliseconds")}')
|
||||
print(f'총 소요 시간: {elapsed:.3f}초 (요청 간 딜레이 포함)', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,17 @@
|
||||
<!--
|
||||
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h1/h2/h3/p/ul/li/strong)만 사용.
|
||||
admin Summernote 코드뷰에 그대로 붙여넣으면 됨. class/style 속성 없음 -> 붙여넣기(paste-cleaner)든
|
||||
코드뷰 직접 입력이든 태그 정리 과정에서 잘려나갈 것이 없다.
|
||||
-->
|
||||
<h1>개인정보수집동의서</h1>
|
||||
<p><strong>(주)제주은행 귀중</strong></p>
|
||||
<p>귀 행과의 Open API Market 회원가입 거래와 관련하여 귀 행이 본인의 개인정보를 수집·이용하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 본인의 동의가 필요합니다.</p>
|
||||
<h2>개인정보</h2>
|
||||
<ul>
|
||||
<li><strong>일반 개인정보:</strong> 성명, 직장명, 부서명, 연락처(휴대폰 번호, 직장번호), 이메일주소, 아이디, 비밀번호</li>
|
||||
</ul>
|
||||
<p>□ 동의하지 않음 □ 동의함</p>
|
||||
<p>※ 본 동의 이후 동일한 목적 또는 이용범위 내에서 개인정보의 정확성, 최신성을 유지하기 위해 제공하는 경우에는 별도의 추가 동의가 필요하지 않습니다.</p>
|
||||
<p>※ 은행의 고의 또는 과실 등 귀책사유로 인한 개인정보 유출로 고객님에게 발생한 손해에 대해 관계 법령 등에 따라 보상받으실 수 있습니다.</p>
|
||||
<h3>위 개인정보 수집·이용에 동의하십니까?</h3>
|
||||
<p>제정일자: 2026. 99. 99. 개정</p>
|
||||
@@ -0,0 +1,19 @@
|
||||
개인정보수집동의서
|
||||
|
||||
(주)제주은행 귀중
|
||||
|
||||
귀 행과의 Open API Market 회원가입 거래와 관련하여 귀 행이 본인의 개인정보를 수집·이용하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 본인의 동의가 필요합니다.
|
||||
|
||||
개인정보
|
||||
ㄴ 일반 개인정보
|
||||
성명, 직장명, 부서명, 연락처(휴대폰 번호, 직장번호), 이메일주소, 아이디, 비밀번호
|
||||
|
||||
□ 동의하지 않음 □ 동의함
|
||||
|
||||
※ 본 동의 이후 동일한 목적 또는 이용범위 내에서 개인정보의 정확성, 최신성을 유지하기 위해 제공하는 경우에는 별도의 추가 동의가 필요하지 않습니다.
|
||||
|
||||
※ 은행의 고의 또는 과실 등 귀책사유로 인한 개인정보 유출로 고객님에게 발생한 손해에 대해 관계 법령 등에 따라 보상받으실 수 있습니다.
|
||||
|
||||
위 개인정보 수집·이용에 동의하십니까?
|
||||
|
||||
제정일자: 2026. 99. 99. 개정
|
||||
@@ -0,0 +1,162 @@
|
||||
<!--
|
||||
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h2/h3/p)만 사용.
|
||||
admin Summernote 코드뷰에 그대로 붙여넣으면 됨(붙여넣기 시 badTags 정리 대상 없음).
|
||||
약관탭(.content-body.editor-content), 회원가입 동의창, admin .note-editable.editor-content 3곳 모두
|
||||
이 파일 안에서는 정의하지 않는 h2/h3/p 스타일은 공용 editor-content.css 가 담당한다.
|
||||
-->
|
||||
<h2>제1장 총칙</h2>
|
||||
<h3>제1조 (목적)</h3>
|
||||
<p>본 약관은 (주)제주은행(이하 “은행”)이 제공하는 Open API 서비스를 이용함에 있어 “은행”과 Open API 서비스를 이용하는 자(이하 “이용기관”)간의 권리, 의무 및 책임 사항, 이용조건 및 절차 등 기본적인 사항을 명확히 규정함을 목적으로 합니다.</p>
|
||||
<h3>제2조 (용어의 정의)</h3>
|
||||
<p> ① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.</p>
|
||||
<p> 1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API</p>
|
||||
<p> 2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것</p>
|
||||
<p> 3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템</p>
|
||||
<p> 4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트</p>
|
||||
<p> 5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)</p>
|
||||
<p> 6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자</p>
|
||||
<p> 7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객</p>
|
||||
<p> 8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자</p>
|
||||
<h3>제3조 (서비스의 내용)</h3>
|
||||
<p> ① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)</p>
|
||||
<p> ② 거래성 API 서비스(환전, 이체 등)</p>
|
||||
<p> ③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”</p>
|
||||
<h3>제4조 (약관의 효력 및 변경)</h3>
|
||||
<p> ① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.</p>
|
||||
<p> ② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.</p>
|
||||
<h2>제2장 서비스 이용계약</h2>
|
||||
<h3>제5조 (서비스 이용신청)</h3>
|
||||
<p> ① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.</p>
|
||||
<p> ② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.</p>
|
||||
<h3>제6조 (서비스 이용신청 승인 및 거절)</h3>
|
||||
<p> ① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.</p>
|
||||
<p> ② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.</p>
|
||||
<p> 1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우</p>
|
||||
<p> 2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우</p>
|
||||
<p> 3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우</p>
|
||||
<p> 4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우</p>
|
||||
<p> 5. “이용신청기관”의 사업모델에 위법성이 있는 경우</p>
|
||||
<p> 6. “이용신청기관”의 재무안정성이 위험한 경우</p>
|
||||
<p> 7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우</p>
|
||||
<p> 8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우</p>
|
||||
<p> ③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.</p>
|
||||
<h2>제3장 서비스의 이용</h2>
|
||||
<h3>제7조 (서비스 이용개시)</h3>
|
||||
<p>“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.</p>
|
||||
<h3>제8조 (서비스 이용시간)</h3>
|
||||
<p> ① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.</p>
|
||||
<p> 1. 은행의 시스템을 점검하는 경우</p>
|
||||
<p> 2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우</p>
|
||||
<p> 3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우</p>
|
||||
<p> 4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우</p>
|
||||
<p> 5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우</p>
|
||||
<p> ② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.</p>
|
||||
<h3>제9조 (서비스 변경 및 중단)</h3>
|
||||
<p> ① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.</p>
|
||||
<p> ② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.</p>
|
||||
<p> 1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우</p>
|
||||
<p> 2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우</p>
|
||||
<p> 3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우</p>
|
||||
<p> 4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우</p>
|
||||
<p> ③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||
<p> ④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||
<h3>제10조 (서비스 이용 제한)</h3>
|
||||
<p> ① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.</p>
|
||||
<p> 1. 감독당국으로부터 중지 권고가 있는 경우</p>
|
||||
<p> 2. “이용기관”이 휴업 또는 폐업을 하는 경우</p>
|
||||
<p> 3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우</p>
|
||||
<p> 4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우</p>
|
||||
<p> 5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우</p>
|
||||
<p> 6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우</p>
|
||||
<p> ② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.</p>
|
||||
<h3>제11조 (서비스 이용 해지)</h3>
|
||||
<p> ① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.</p>
|
||||
<p> 1. 감독당국으로부터 해지권고가 있는 경우</p>
|
||||
<p> 2. 최근 12개월간 “시스템” 이용 실적이 없는 경우</p>
|
||||
<p> 3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우</p>
|
||||
<p> 4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우</p>
|
||||
<p> ② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.</p>
|
||||
<p> ③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.</p>
|
||||
<h2>제4장 계약당사자의 의무 및 손해배상 등</h2>
|
||||
<h3>제12조 (은행의 의무)</h3>
|
||||
<p> ① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.</p>
|
||||
<p> ② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.</p>
|
||||
<p> ③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.</p>
|
||||
<p> ④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
|
||||
<h3>제13조 (약관 외 준칙)</h3>
|
||||
<p> ① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.</p>
|
||||
<p> ② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.</p>
|
||||
<p> ③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.</p>
|
||||
<p> ④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.</p>
|
||||
<p> ⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.</p>
|
||||
<p> ⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.</p>
|
||||
<p> ⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.</p>
|
||||
<p> ⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.</p>
|
||||
<p> 1. 다른 “이용기관”의 아이디를 부정 사용하는 행위</p>
|
||||
<p> 2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위</p>
|
||||
<p> 3. 선량한 풍속, 기타 사회질서를 해하는 행위</p>
|
||||
<p> 4. 타인의 명예를 훼손하거나 모욕하는 행위</p>
|
||||
<p> 5. 타인의 지식재산권 등의 권리를 침해하는 행위</p>
|
||||
<p> 6. 해킹행위 또는 컴퓨터 바이러스의 유포행위</p>
|
||||
<p> 7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위</p>
|
||||
<p> 8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위</p>
|
||||
<p> ⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
|
||||
<h2>제5장 수수료</h2>
|
||||
<h3>제14조 (수수료)</h3>
|
||||
<p> ① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.</p>
|
||||
<p> ② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.</p>
|
||||
<p> ③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.</p>
|
||||
<p> ④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.</p>
|
||||
<p> ⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.</p>
|
||||
<p> ⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.</p>
|
||||
<h2>제6장 보칙</h2>
|
||||
<h3>제15조 (최종사용자에 대한 고지 의무)</h3>
|
||||
<p> ① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.</p>
|
||||
<p> ② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.</p>
|
||||
<p> ③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.</p>
|
||||
<p> ④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.</p>
|
||||
<h3>제16조 (부정 접속 방지)</h3>
|
||||
<p> ① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.</p>
|
||||
<p> ② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.</p>
|
||||
<h3>제17조 (전산처리 절차)</h3>
|
||||
<p>전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.</p>
|
||||
<h3>제18조 (보안관리)</h3>
|
||||
<p> ① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.</p>
|
||||
<p> 1. 개인정보 및 금융정보 암호화 운영현황</p>
|
||||
<p> 2. 해킹 등에 대비한 정책사항</p>
|
||||
<p> 3. 시스템 운영에 대한 정보보호 체계 등</p>
|
||||
<p> ② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.</p>
|
||||
<p> ③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.</p>
|
||||
<h3>제19조 (기밀유지)</h3>
|
||||
<p> ① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.</p>
|
||||
<p> ② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.</p>
|
||||
<h3>제20조 (손해배상 및 면책)</h3>
|
||||
<p> ① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
|
||||
<p> ② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
|
||||
<p> 1. 접근매체의 위조나 변조로 발생한 사고</p>
|
||||
<p> 2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고</p>
|
||||
<p> 3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고</p>
|
||||
<p> ③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.</p>
|
||||
<p> ④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||
<p> 1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)</p>
|
||||
<p> 2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우</p>
|
||||
<p> 3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우</p>
|
||||
<p> 4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우</p>
|
||||
<p> 가. 누설·노출 또는 방치한 행위</p>
|
||||
<p> 나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위</p>
|
||||
<p> 5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우</p>
|
||||
<h3>제21조 (이의제기 및 협조)</h3>
|
||||
<p> ① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.</p>
|
||||
<p> ② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.</p>
|
||||
<p> ③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.</p>
|
||||
<p> ④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.</p>
|
||||
<h3>제22조 (거래기록·자료의 제공)</h3>
|
||||
<p> ① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.</p>
|
||||
<p> ② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.</p>
|
||||
<p> ③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.</p>
|
||||
<p> ④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.</p>
|
||||
<p> ⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.</p>
|
||||
<p> 1. 전자적 장치를 통한 제공의 경우에는 즉시</p>
|
||||
<p> 2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부</p>
|
||||
<h2>부칙</h2>
|
||||
<p>본 약관은 2026. 99. 99.부터 시행합니다.</p>
|
||||
@@ -0,0 +1,184 @@
|
||||
제1장 총칙
|
||||
|
||||
제1조 (목적)
|
||||
본 약관은 (주)제주은행(이하 “은행”)이 제공하는 Open API 서비스를 이용함에 있어 “은행”과 Open API 서비스를 이용하는 자(이하 “이용기관”)간의 권리, 의무 및 책임 사항, 이용조건 및 절차 등 기본적인 사항을 명확히 규정함을 목적으로 합니다.
|
||||
|
||||
제2조 (용어의 정의)
|
||||
① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.
|
||||
1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API
|
||||
2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것
|
||||
3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템
|
||||
4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트
|
||||
5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)
|
||||
6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자
|
||||
7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객
|
||||
8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자
|
||||
|
||||
제3조 (서비스의 내용)
|
||||
① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)
|
||||
② 거래성 API 서비스(환전, 이체 등)
|
||||
③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”
|
||||
|
||||
제4조 (약관의 효력 및 변경)
|
||||
① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.
|
||||
② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.
|
||||
|
||||
제2장 서비스 이용계약
|
||||
|
||||
제5조 (서비스 이용신청)
|
||||
① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.
|
||||
② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.
|
||||
|
||||
제6조 (서비스 이용신청 승인 및 거절)
|
||||
① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.
|
||||
② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.
|
||||
1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우
|
||||
2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우
|
||||
3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우
|
||||
4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우
|
||||
5. “이용신청기관”의 사업모델에 위법성이 있는 경우
|
||||
6. “이용신청기관”의 재무안정성이 위험한 경우
|
||||
7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우
|
||||
8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우
|
||||
③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.
|
||||
|
||||
제3장 서비스의 이용
|
||||
|
||||
제7조 (서비스 이용개시)
|
||||
“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.
|
||||
|
||||
제8조 (서비스 이용시간)
|
||||
① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.
|
||||
1. 은행의 시스템을 점검하는 경우
|
||||
2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우
|
||||
3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우
|
||||
4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우
|
||||
5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우
|
||||
② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.
|
||||
|
||||
제9조 (서비스 변경 및 중단)
|
||||
① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.
|
||||
② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.
|
||||
1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우
|
||||
2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우
|
||||
3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우
|
||||
4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우
|
||||
③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.
|
||||
④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.
|
||||
|
||||
제10조 (서비스 이용 제한)
|
||||
① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.
|
||||
1. 감독당국으로부터 중지 권고가 있는 경우
|
||||
2. “이용기관”이 휴업 또는 폐업을 하는 경우
|
||||
3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우
|
||||
4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우
|
||||
5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우
|
||||
6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우
|
||||
② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.
|
||||
|
||||
제11조 (서비스 이용 해지)
|
||||
① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.
|
||||
1. 감독당국으로부터 해지권고가 있는 경우
|
||||
2. 최근 12개월간 “시스템” 이용 실적이 없는 경우
|
||||
3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우
|
||||
4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우
|
||||
② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.
|
||||
③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.
|
||||
|
||||
제4장 계약당사자의 의무 및 손해배상 등
|
||||
|
||||
제12조 (은행의 의무)
|
||||
① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.
|
||||
② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.
|
||||
③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.
|
||||
④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.
|
||||
|
||||
제13조 (약관 외 준칙)
|
||||
① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.
|
||||
② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.
|
||||
③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.
|
||||
④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.
|
||||
⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.
|
||||
⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.
|
||||
⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.
|
||||
⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.
|
||||
1. 다른 “이용기관”의 아이디를 부정 사용하는 행위
|
||||
2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위
|
||||
3. 선량한 풍속, 기타 사회질서를 해하는 행위
|
||||
4. 타인의 명예를 훼손하거나 모욕하는 행위
|
||||
5. 타인의 지식재산권 등의 권리를 침해하는 행위
|
||||
6. 해킹행위 또는 컴퓨터 바이러스의 유포행위
|
||||
7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위
|
||||
8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위
|
||||
⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.
|
||||
|
||||
제5장 수수료
|
||||
|
||||
제14조 (수수료)
|
||||
① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.
|
||||
② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.
|
||||
③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.
|
||||
④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.
|
||||
⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.
|
||||
⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.
|
||||
|
||||
제6장 보칙
|
||||
|
||||
제15조 (최종사용자에 대한 고지 의무)
|
||||
① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.
|
||||
② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.
|
||||
③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.
|
||||
④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.
|
||||
|
||||
제16조 (부정 접속 방지)
|
||||
① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.
|
||||
② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.
|
||||
|
||||
제17조 (전산처리 절차)
|
||||
전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.
|
||||
|
||||
제18조 (보안관리)
|
||||
① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.
|
||||
1. 개인정보 및 금융정보 암호화 운영현황
|
||||
2. 해킹 등에 대비한 정책사항
|
||||
3. 시스템 운영에 대한 정보보호 체계 등
|
||||
② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.
|
||||
③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.
|
||||
|
||||
제19조 (기밀유지)
|
||||
① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.
|
||||
② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.
|
||||
|
||||
제20조 (손해배상 및 면책)
|
||||
① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.
|
||||
② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.
|
||||
1. 접근매체의 위조나 변조로 발생한 사고
|
||||
2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고
|
||||
3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고
|
||||
③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.
|
||||
④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.
|
||||
1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)
|
||||
2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우
|
||||
3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우
|
||||
4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우
|
||||
가. 누설·노출 또는 방치한 행위
|
||||
나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위
|
||||
5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우
|
||||
|
||||
제21조 (이의제기 및 협조)
|
||||
① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.
|
||||
② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.
|
||||
③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.
|
||||
④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.
|
||||
|
||||
제22조 (거래기록·자료의 제공)
|
||||
① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.
|
||||
② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.
|
||||
③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.
|
||||
④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.
|
||||
⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.
|
||||
1. 전자적 장치를 통한 제공의 경우에는 즉시
|
||||
2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부
|
||||
|
||||
부칙
|
||||
본 약관은 2026. 99. 99.부터 시행합니다.
|
||||
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h1/h2/h3/p/ul/li/strong)만 사용.
|
||||
admin Summernote 코드뷰에 그대로 붙여넣으면 됨. class/style 속성 없음 -> 붙여넣기(paste-cleaner)든
|
||||
코드뷰 직접 입력이든 태그 정리 과정에서 잘려나갈 것이 없다.
|
||||
-->
|
||||
<h1>알림수신동의서</h1>
|
||||
<p><strong>(주)제주은행 귀중</strong></p>
|
||||
<p>귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.</p>
|
||||
<h2>발송정보</h2>
|
||||
<ul>
|
||||
<li><strong>수집·이용 항목:</strong> 휴대폰 번호</li>
|
||||
<li><strong>발송 목적:</strong> Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송</li>
|
||||
<li><strong>보유·이용 기간:</strong> 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기</li>
|
||||
</ul>
|
||||
<p>□ 동의하지 않음 □ 동의함</p>
|
||||
<p>※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.</p>
|
||||
<p>※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.</p>
|
||||
<p>※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.</p>
|
||||
<h3>위 알림 수신에 동의하십니까?</h3>
|
||||
<p>제정일자: 2026. 99. 99. 제정</p>
|
||||
@@ -0,0 +1,22 @@
|
||||
알림수신동의서
|
||||
|
||||
(주)제주은행 귀중
|
||||
|
||||
귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.
|
||||
|
||||
발송정보
|
||||
ㄴ 수집·이용 항목: 휴대폰 번호
|
||||
ㄴ 발송 목적: Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송
|
||||
ㄴ 보유·이용 기간: 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기
|
||||
|
||||
□ 동의하지 않음 □ 동의함
|
||||
|
||||
※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.
|
||||
|
||||
※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.
|
||||
|
||||
※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.
|
||||
|
||||
위 알림 수신에 동의하십니까?
|
||||
|
||||
제정일자: 2026. 99. 99. 제정
|
||||
@@ -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 |
@@ -0,0 +1,21 @@
|
||||
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
|
||||
// Read-only dependency resolution; does not override versions or the normal build.
|
||||
gradle.projectsEvaluated {
|
||||
def portal = gradle.rootProject
|
||||
portal.tasks.register('thymeleafCompatibilityDependencies') {
|
||||
doLast {
|
||||
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
|
||||
output.mkdirs()
|
||||
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
|
||||
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
|
||||
def rows = artifacts.findAll {
|
||||
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||
}.collect {
|
||||
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
|
||||
}.sort()
|
||||
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
|
||||
}
|
||||
println "Dependency evidence: ${output}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
|
||||
EXPECTED_CHANGES = {
|
||||
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||
"ognl:ognl": ("3.3.4", "3.3.5"),
|
||||
}
|
||||
EXPECTED_CLASSES = {
|
||||
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
|
||||
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
|
||||
"ognl/Ognl.class": "ognl-3.3.5.jar",
|
||||
}
|
||||
LOCAL_PREFIXES = (
|
||||
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
|
||||
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
|
||||
)
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def dependencies(path):
|
||||
result = {}
|
||||
for line in path.read_text().splitlines():
|
||||
module, version, filename = line.split("\t")
|
||||
entries = result.setdefault(module, [])
|
||||
require((version, filename) not in entries, "Duplicate artifact: " + filename)
|
||||
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
|
||||
entries.append((version, filename))
|
||||
for entries in result.values():
|
||||
entries.sort()
|
||||
return result
|
||||
|
||||
|
||||
def compare(before, after):
|
||||
old, new = dependencies(before), dependencies(after)
|
||||
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
|
||||
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
|
||||
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
|
||||
return {"external_modules": len(new), "changes": changed}
|
||||
|
||||
|
||||
def inspect_war(path):
|
||||
owners = {name: [] for name in EXPECTED_CLASSES}
|
||||
with zipfile.ZipFile(path) as war:
|
||||
names = war.namelist()
|
||||
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
|
||||
jars = sorted(name for name in names if name.endswith(".jar"))
|
||||
basenames = [Path(name).name for name in jars]
|
||||
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
|
||||
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
|
||||
for name in names), "Local profile packaged: " + str(path))
|
||||
for name in jars:
|
||||
basename = Path(name).name
|
||||
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
|
||||
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
|
||||
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
|
||||
classes = set(jar.namelist())
|
||||
require(not any(c.startswith("jakarta/servlet/") for c in classes),
|
||||
"Jakarta Servlet classes packaged: " + name)
|
||||
for target in owners:
|
||||
if target in classes:
|
||||
owners[target].append(name)
|
||||
# Java 8 compatibility of each upgraded library's entry class.
|
||||
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
|
||||
"Java >8 class: " + name + "!" + target)
|
||||
for target, expected in EXPECTED_CLASSES.items():
|
||||
require(owners[target] == ["WEB-INF/lib/" + expected],
|
||||
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
|
||||
for pattern, expected in [
|
||||
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
|
||||
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
|
||||
(r"ognl-", "ognl-3.3.5.jar"),
|
||||
]:
|
||||
require([n for n in basenames if re.match(pattern, n)] == [expected],
|
||||
"Wrong/duplicate library version for " + expected)
|
||||
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"jar_count": len(jars), "class_providers": owners, "jars": jars}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
|
||||
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
|
||||
parser.add_argument("--war", type=Path, action="append", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
|
||||
result = {
|
||||
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
|
||||
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
|
||||
"wars": [inspect_war(path) for path in args.war],
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
||||
print("PASS: dependency changes limited to three modules; both WARs verified")
|
||||
for war in result["wars"]:
|
||||
print(war["file"], war["sha256"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,9 +9,454 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"sass": "^1.69.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
@@ -322,6 +767,22 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -349,6 +810,48 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
@@ -389,6 +892,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"dev": true,
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
@@ -403,6 +916,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "eapim-portal",
|
||||
"version": "1.0.0",
|
||||
"description": "SASS build system for EAPIM Portal",
|
||||
"description": "SASS build system + forge custom bundle for EAPIM Portal",
|
||||
"scripts": {
|
||||
"test:moment": "playwright test --config=src/test/js/playwright.config.js",
|
||||
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
|
||||
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||
"build": "npm run sass:build && npm run sass:build:minified",
|
||||
"dev": "npm run sass:watch"
|
||||
"dev": "npm run sass:watch",
|
||||
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"sass": "^1.69.5"
|
||||
},
|
||||
"author": "",
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,69 @@
|
||||
# SonarQube 분석 설정 (SonarScanner CLI 용)
|
||||
#
|
||||
# Jenkinsfile.sonar 가 이 파일을 그대로 읽고, 아래 항목만 실행 시점에 -D 로 덮어쓴다.
|
||||
# sonar.projectVersion : 빌드 번호
|
||||
# sonar.java.libraries : ci/sonar-classpath.gradle 이 덤프한 컴파일 클래스패스
|
||||
# sonar.java.test.libraries : 위 + 테스트 클래스패스
|
||||
# sonar.junit.reportPaths : 테스트를 실행한 경우에만
|
||||
#
|
||||
# 로컬에서 수동 분석 시(스캐너는 JDK 17 필요):
|
||||
# gradle clean classes testClasses
|
||||
# gradle -I ci/sonar-classpath.gradle exportSonarClasspath
|
||||
# sonar-scanner -Dsonar.host.url=... -Dsonar.token=... \
|
||||
# -Dsonar.java.libraries="$(cat build/sonar/java-libraries.txt)"
|
||||
|
||||
sonar.projectKey=djb-eapim-portal
|
||||
sonar.projectName=DJB eAPIM Portal
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# --- 분석 대상 -------------------------------------------------------------
|
||||
# src/main/resources 를 포함시켜 Thymeleaf 템플릿(XSS), yml/properties(하드코딩 시크릿)까지 검사한다.
|
||||
sonar.sources=src/main/java,src/main/resources
|
||||
sonar.tests=src/test/java
|
||||
|
||||
# --- Java ------------------------------------------------------------------
|
||||
# 소스는 Java 8. 스캐너 실행 JRE 는 별개로 17 이상이어야 한다(Jenkinsfile.sonar 참고).
|
||||
sonar.java.source=8
|
||||
sonar.java.binaries=build/classes/java/main
|
||||
sonar.java.test.binaries=build/classes/java/test
|
||||
|
||||
# --- 제외 대상 -------------------------------------------------------------
|
||||
# 서드파티 번들/컴파일 산출물/바이너리. 분석해도 우리가 고칠 수 없는 코드는 뺀다.
|
||||
sonar.exclusions=\
|
||||
**/build/**,\
|
||||
**/node_modules/**,\
|
||||
**/lib/**,\
|
||||
**/libs/**,\
|
||||
**/local_jars/**,\
|
||||
src/main/resources/static/plugins/**,\
|
||||
src/main/resources/static/js/egovframework/**,\
|
||||
src/main/resources/static/js/**/*.min.js,\
|
||||
src/main/resources/static/js/lodash.js,\
|
||||
src/main/resources/static/js/popper.js,\
|
||||
src/main/resources/static/js/daterangepicker.js,\
|
||||
src/main/resources/static/js/json-lint.js,\
|
||||
src/main/resources/static/html/**,\
|
||||
src/main/resources/static/css/**,\
|
||||
src/main/resources/static/sass/**,\
|
||||
src/main/resources/static/font/**,\
|
||||
src/main/resources/static/webfonts/**,\
|
||||
src/main/resources/static/img/**
|
||||
|
||||
# 자동 생성 코드(MapStruct Impl, QueryDSL Q클래스)는 build/ 아래라 이미 제외된다.
|
||||
|
||||
# Thymeleaf 인라인 표현식([[${...}]], /*[[${...}]]*/)은 JS 파서가 읽지 못해
|
||||
# "Failed to parse file ... Unexpected token" 으로 분석이 중단된다.
|
||||
# JS 분석에서만 빼고 HTML(web) 센서는 그대로 두어 템플릿 XSS 룰은 유지한다.
|
||||
# 기본값이 **/node_modules/** 이므로 재정의 시 함께 명시해야 한다.
|
||||
sonar.javascript.exclusions=\
|
||||
**/node_modules/**,\
|
||||
src/main/resources/templates/**
|
||||
|
||||
# --- SCM -------------------------------------------------------------------
|
||||
# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다.
|
||||
sonar.scm.provider=git
|
||||
|
||||
# --- 브랜치 ----------------------------------------------------------------
|
||||
# sonar.branch.name 은 SonarQube Developer Edition 이상에서만 동작한다.
|
||||
# Community Edition 에서 지정하면 분석이 실패하므로 기본적으로 설정하지 않는다.
|
||||
# sonar.branch.name=feats/ci-test
|
||||
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
|
||||
*/
|
||||
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
||||
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
||||
|
||||
/**
|
||||
* 다건 org 소속 CLIENTID 목록 (인덱스 페이지 전체 통계 집계용).
|
||||
*/
|
||||
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId IN :orgIds")
|
||||
List<String> findClientIdsByOrgIdIn(@Param("orgIds") List<String> orgIds);
|
||||
}
|
||||
|
||||
@@ -5,19 +5,36 @@ import java.sql.SQLException;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
|
||||
/**
|
||||
* Readiness probe.
|
||||
*
|
||||
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
|
||||
* This endpoint validates both EMS and Gateway datasources via JDBC
|
||||
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
|
||||
* requests that depend on the database.
|
||||
* {@code Connection.isValid(timeout)}, plus three libraries that WebLogic's
|
||||
* oracle_common bundles at a different version from this app (see weblogic.xml
|
||||
* {@code prefer-application-packages}): Thymeleaf's {@link SpringTemplateEngine}
|
||||
* (jackson), jboss-logging, and Guava.
|
||||
*
|
||||
* <p>These exist because this controller bypasses view resolution entirely
|
||||
* (plain {@code @RestController} JSON) — a classpath split between the app's
|
||||
* copy and WebLogic's bundled copy of a preferred package can throw
|
||||
* {@link LinkageError}/{@link java.util.ServiceConfigurationError} only on the
|
||||
* real code paths that touch it, while this probe kept reporting 200. That
|
||||
* already happened once for jackson (Thymeleaf's dialect/module discovery
|
||||
* failed on every real page while /health/ready stayed green) — jboss-logging
|
||||
* and Guava carry the same risk (confirmed version mismatch against
|
||||
* oracle_common, not yet observed failing in production) so they get the same
|
||||
* kind of forced-touch check here.
|
||||
*
|
||||
* <p>HTTP 200 + JSON when all checks pass.
|
||||
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
|
||||
@@ -30,24 +47,34 @@ public class ReadinessController {
|
||||
|
||||
private final DataSource portalDataSource;
|
||||
private final DataSource gatewayDataSource;
|
||||
private final SpringTemplateEngine templateEngine;
|
||||
|
||||
public ReadinessController(
|
||||
@Qualifier("portalDataSource") DataSource portalDataSource,
|
||||
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
|
||||
@Qualifier("gatewayDataSource") DataSource gatewayDataSource,
|
||||
SpringTemplateEngine templateEngine) {
|
||||
this.portalDataSource = portalDataSource;
|
||||
this.gatewayDataSource = gatewayDataSource;
|
||||
this.templateEngine = templateEngine;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> ready() {
|
||||
String ems = check(portalDataSource);
|
||||
String gw = check(gatewayDataSource);
|
||||
boolean ok = "UP".equals(ems) && "UP".equals(gw);
|
||||
String ems = check(portalDataSource);
|
||||
String gw = check(gatewayDataSource);
|
||||
String view = checkTemplateEngine();
|
||||
String logging = checkJbossLogging();
|
||||
String guava = checkGuava();
|
||||
boolean ok = "UP".equals(ems) && "UP".equals(gw) && "UP".equals(view)
|
||||
&& "UP".equals(logging) && "UP".equals(guava);
|
||||
|
||||
String body = "{"
|
||||
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
||||
+ "\"ems\":\"" + ems + "\","
|
||||
+ "\"gateway\":\""+ gw + "\""
|
||||
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
||||
+ "\"ems\":\"" + ems + "\","
|
||||
+ "\"gateway\":\"" + gw + "\","
|
||||
+ "\"view\":\"" + view + "\","
|
||||
+ "\"logging\":\"" + logging + "\","
|
||||
+ "\"guava\":\"" + guava + "\""
|
||||
+ "}";
|
||||
|
||||
return ResponseEntity.status(ok ? 200 : 503)
|
||||
@@ -55,6 +82,49 @@ public class ReadinessController {
|
||||
.body(body);
|
||||
}
|
||||
|
||||
/** {@code getConfiguration()} lazily runs Thymeleaf's dialect/module init on first call
|
||||
* and caches it — so this is cheap once healthy, and reproduces the exact failure path
|
||||
* a real page render would hit. Catches {@link Throwable}: init failures here have
|
||||
* surfaced as {@link Error} (ServiceConfigurationError), not just Exception. */
|
||||
private String checkTemplateEngine() {
|
||||
try {
|
||||
templateEngine.getConfiguration();
|
||||
return "UP";
|
||||
} catch (Throwable t) {
|
||||
return down(t);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forces jboss-logging's {@code Logger} + its bound provider (LogManager/JBoss/JUL
|
||||
* discovery) to load and actually run a log-level check, not just resolve the class. */
|
||||
private static String checkJbossLogging() {
|
||||
try {
|
||||
Logger log = Logger.getLogger(ReadinessController.class);
|
||||
log.isDebugEnabled();
|
||||
return "UP";
|
||||
} catch (Throwable t) {
|
||||
return down(t);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code Futures.immediateFuture} touches Guava's {@code util.concurrent} machinery
|
||||
* (AbstractFuture/InternalFutureFailureAccess) — the part backed by the separate
|
||||
* {@code failureaccess} jar, which is exactly where an app/WebLogic version mismatch
|
||||
* would split across classloaders. */
|
||||
private static String checkGuava() {
|
||||
try {
|
||||
Futures.immediateFuture(Boolean.TRUE).isDone();
|
||||
return "UP";
|
||||
} catch (Throwable t) {
|
||||
return down(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static String down(Throwable t) {
|
||||
String msg = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
|
||||
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
|
||||
}
|
||||
|
||||
private static String check(DataSource ds) {
|
||||
if (ds == null) return "DOWN:NO_DATASOURCE";
|
||||
try (Connection c = ds.getConnection()) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.apis.controller;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
@@ -15,6 +16,11 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.ui.ModelMap;
|
||||
@@ -33,6 +39,7 @@ public class ApiController {
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final ApiSearchFacade apiSearchFacade;
|
||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||
private final ApiListProperties apiListProperties;
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||
|
||||
@@ -79,12 +86,18 @@ public class ApiController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String apiList(@ModelAttribute ApiGroupSearch search, Model model) {
|
||||
public String apiList(@ModelAttribute ApiGroupSearch search, @PageableDefault Pageable pageable, Model model) {
|
||||
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
|
||||
Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort());
|
||||
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, listPageable);
|
||||
|
||||
model.addAttribute("search", search);
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
model.addAttribute("apis", searchResult.get("apis"));
|
||||
model.addAttribute("apis", apiPage.getContent());
|
||||
model.addAttribute("page", apiPage);
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||
@@ -94,6 +107,19 @@ public class ApiController {
|
||||
return "apps/apis/mainApiList";
|
||||
}
|
||||
|
||||
/**
|
||||
* apiSearchFacade.searchApis() 는 다른 소비처(API Status 필터 목록 등)와 계약을 공유하므로
|
||||
* 항상 전체 List 를 돌려준다. 목록 화면 렌더링에서만 결과를 잘라 Page 로 감싼다.
|
||||
*/
|
||||
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
||||
int total = apis == null ? 0 : apis.size();
|
||||
int fromIndex = (int) Math.min(pageable.getOffset(), total);
|
||||
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
|
||||
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
||||
|
||||
return new PageImpl<>(content, pageable, total);
|
||||
}
|
||||
|
||||
@GetMapping("/testbed/api")
|
||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
|
||||
@@ -44,13 +44,14 @@ public class TestbedSpecController {
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
/** 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()), StandardCharsets.UTF_8);
|
||||
return serverRewriter.rewriteServer(content, null, request);
|
||||
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 null;
|
||||
@@ -61,6 +62,6 @@ public class TestbedSpecController {
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
}
|
||||
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
|
||||
return serverRewriter.rewriteServerToGateway(spec.get().getTestbedSpec(), request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
|
||||
private String displayRoleCode;
|
||||
|
||||
private String apiGroupName;
|
||||
|
||||
private String apiGroupId;
|
||||
}
|
||||
|
||||
@@ -12,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;
|
||||
@@ -22,6 +25,57 @@ public class APISender {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
||||
|
||||
/** 응답 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;
|
||||
|
||||
@@ -36,7 +90,7 @@ public class APISender {
|
||||
connection.setReadTimeout(t);
|
||||
}
|
||||
|
||||
public String requestPost(String uri, String requestBody) throws IOException {
|
||||
public ApiResponse requestPost(String uri, String requestBody) throws IOException {
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
|
||||
@@ -45,11 +99,11 @@ public class APISender {
|
||||
|
||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||
|
||||
String response = getResponse(connection);
|
||||
ApiResponse response = getResponse(connection);
|
||||
|
||||
connection.disconnect();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
|
||||
logger.debug("APISender POST(json) 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -75,7 +129,7 @@ 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();
|
||||
@@ -95,16 +149,16 @@ public class APISender {
|
||||
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
|
||||
}
|
||||
|
||||
String response = getResponse(connection);
|
||||
ApiResponse response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender GET 응답 - uri={}, response={}", uri, 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();
|
||||
@@ -132,30 +186,67 @@ public class APISender {
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
String response = getResponse(connection);
|
||||
ApiResponse response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST 응답 - uri={}, response={}", uri, 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);
|
||||
BufferedReader reader = new BufferedReader(isr)) {
|
||||
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;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.toString();
|
||||
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 {
|
||||
|
||||
@@ -11,9 +11,13 @@ 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;
|
||||
@@ -32,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");
|
||||
@@ -141,14 +152,16 @@ public class ApiTesterFilter implements Filter {
|
||||
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
|
||||
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
|
||||
}
|
||||
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
APISender.ApiResponse tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(tokenResponse);
|
||||
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);
|
||||
|
||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||
if (apiSpecInfoDto == null) {
|
||||
@@ -190,11 +203,12 @@ public class ApiTesterFilter implements Filter {
|
||||
String targetUri;
|
||||
Map<String, String[]> paramMap;
|
||||
if (gw) {
|
||||
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
|
||||
// original-url 은 브라우저가 spec 서버주소(대외)로 만든 값이라 포탈 서버에서 도달하지 못할
|
||||
// 수 있다. path/query 만 떼어 대내 base-url 에 재조립한 주소로 forward 한다.
|
||||
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
|
||||
headers.remove("host");
|
||||
headers.remove("Host");
|
||||
targetUri = url;
|
||||
targetUri = gatewayProperty.resolveGatewayCallUrl(url);
|
||||
paramMap = new HashMap<>();
|
||||
} else {
|
||||
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
|
||||
@@ -212,19 +226,18 @@ public class ApiTesterFilter implements Filter {
|
||||
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
|
||||
}
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
String responseStr;
|
||||
APISender.ApiResponse upstream;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||
upstream = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
upstream = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
}
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
|
||||
auditType, auditId, targetUri,
|
||||
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
|
||||
logger.debug("{} response - auditId={}, target={}, status={}, respLen={}, preview={}",
|
||||
auditType, auditId, targetUri, upstream.getStatus(),
|
||||
upstream.getBody() == null ? 0 : upstream.getBody().length(), previewOf(upstream.getBody()));
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
writeUpstream(response, upstream);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
@@ -305,6 +318,25 @@ public class ApiTesterFilter implements Filter {
|
||||
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<>();
|
||||
@@ -320,7 +352,53 @@ public class ApiTesterFilter implements Filter {
|
||||
return paramMap;
|
||||
}
|
||||
|
||||
/** 상태코드 + JSON 본문 응답. */
|
||||
/**
|
||||
* 프록시 대상(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 {
|
||||
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("{");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.eactive.apim.portal.apps.apis.service;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** OPEN API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 공통 페이지 크기. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiListProperties {
|
||||
|
||||
private static final String PAGE_SIZE_PROPERTY = "api.list.page-size";
|
||||
private static final int DEFAULT_PAGE_SIZE = 15;
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** PTL_PROPERTY(Portal / api.list.page-size)를 조회하고, 없으면 기본값으로 생성한다. */
|
||||
public int getPageSize() {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
"Portal", PAGE_SIZE_PROPERTY, String.valueOf(DEFAULT_PAGE_SIZE),
|
||||
"API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 페이지당 노출 개수 (양의 정수, 기본 15)");
|
||||
try {
|
||||
int size = Integer.parseInt(value == null ? "" : value.trim());
|
||||
if (size > 0) {
|
||||
return size;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 비어 있거나 정수가 아닌 설정은 기본값을 사용한다.
|
||||
}
|
||||
log.warn("{} 값이 올바르지 않음('{}') - 기본값 {} 사용", PAGE_SIZE_PROPERTY, value, DEFAULT_PAGE_SIZE);
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
|
||||
filteredApis.forEach(api -> {
|
||||
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
|
||||
if (service != null) {
|
||||
api.setMainIcon(service.getMainIcon());
|
||||
// mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
|
||||
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
|
||||
api.setApiGroupId(service.getId());
|
||||
api.setApiGroupName(service.getGroupName());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,8 +5,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
|
||||
@RequiredArgsConstructor
|
||||
public class ApiServiceController {
|
||||
|
||||
// ApiGroup.mainIcon 은 "data:image/png;base64,...." 형태의 Data URL 문자열 그대로 저장돼 있다.
|
||||
private static final Pattern DATA_URL_PATTERN = Pattern.compile("^data:(image/[a-zA-Z0-9+.-]+);base64,(.+)$", Pattern.DOTALL);
|
||||
private static final String DEFAULT_ICON_PATH = "/img/api_icon_default.png";
|
||||
|
||||
private final ApiServiceService apiServiceService;
|
||||
|
||||
|
||||
@@ -52,4 +65,28 @@ public class ApiServiceController {
|
||||
|
||||
return "apps/apiservice/apiServiceDetail";
|
||||
}
|
||||
|
||||
/**
|
||||
* API 그룹 아이콘 스트리밍. mainIcon(CLOB, Data URL 문자열)을 그대로 HTML에 인라인하면
|
||||
* 카드 개수만큼 반복 전송돼 응답 용량이 폭증하므로, 그룹 id 당 1회만 내려주고
|
||||
* 브라우저 캐시로 재사용시킨다.
|
||||
*/
|
||||
@GetMapping("/{id}/icon")
|
||||
public ResponseEntity<byte[]> icon(@PathVariable String id) {
|
||||
String mainIcon = apiServiceService.getMainIcon(id);
|
||||
Matcher matcher = mainIcon == null ? null : DATA_URL_PATTERN.matcher(mainIcon);
|
||||
|
||||
if (matcher == null || !matcher.matches()) {
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header(HttpHeaders.LOCATION, DEFAULT_ICON_PATH)
|
||||
.build();
|
||||
}
|
||||
|
||||
byte[] imageBytes = Base64.getDecoder().decode(matcher.group(2));
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(matcher.group(1)))
|
||||
.cacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic())
|
||||
.body(imageBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,19 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* API 그룹(전시) 조회 서비스.
|
||||
*
|
||||
* <p>주 조회 대상 {@link ApiGroup} 은 <b>AGW(게이트웨이) 데이터소스</b> 엔티티라
|
||||
* 트랜잭션 매니저를 {@code gatewayTransactionManager} 로 지정한다. 기본
|
||||
* {@code transactionManager}(EMS) 를 쓰면 게이트웨이 EntityManager 가 리포지토리 호출 단위로
|
||||
* 닫혀 {@code ApiGroup.apiGroupApiList} 지연 로딩에서 LazyInitializationException 이 난다.</p>
|
||||
*
|
||||
* <p>내부에서 호출하는 {@code ApiSpecInfoService} 는 EMS 쪽이며 자체 {@code @Transactional}
|
||||
* (기본 매니저)로 독립 트랜잭션을 연다. {@code ApiSpecInfo} 에는 지연 연관이 없어 문제되지 않는다.</p>
|
||||
*/
|
||||
@Service("apiServiceService")
|
||||
@Transactional
|
||||
@Transactional("gatewayTransactionManager")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ApiServiceService {
|
||||
@@ -109,5 +120,14 @@ public class ApiServiceService {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
|
||||
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
|
||||
*/
|
||||
public String getMainIcon(String id) {
|
||||
return apiServiceRepository.findById(id)
|
||||
.map(ApiGroup::getMainIcon)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@@ -155,10 +156,10 @@ public class ApiSpecService {
|
||||
updateReferences(pathItem, apiId);
|
||||
|
||||
String tmpFullPath = StringUtils.isNotEmpty(basePath)
|
||||
? StringUtils.join(basePath, "/", StringUtils.removeStart(path, "/"))
|
||||
? StringUtils.join(basePath, "/", Strings.CS.removeStart(path, "/"))
|
||||
: path;
|
||||
|
||||
final String fullPath = StringUtils.replacePattern(tmpFullPath, "//+", "/");
|
||||
final String fullPath = tmpFullPath.replaceAll("//+", "/");
|
||||
|
||||
// HTTP 메소드별로 중복 체크
|
||||
pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
|
||||
@@ -294,7 +295,7 @@ public class ApiSpecService {
|
||||
return StringUtils.EMPTY;
|
||||
}
|
||||
Server server = api.getServers().get(0);
|
||||
return StringUtils.removeEnd(server.getUrl(), "/");
|
||||
return Strings.CS.removeEnd(server.getUrl(), "/");
|
||||
}
|
||||
|
||||
private void mergeComponents(OpenAPI currentAPI, String apiId, String apiName, Map<String, Object> mergedComponents, ObjectMapper objectMapper) {
|
||||
|
||||
@@ -23,6 +23,8 @@ 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;
|
||||
@@ -102,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);
|
||||
}
|
||||
|
||||
@@ -184,6 +206,10 @@ public class MyAppController {
|
||||
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);
|
||||
}
|
||||
@@ -247,6 +273,36 @@ public class MyAppController {
|
||||
* @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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.app.service;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
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>
|
||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
|
||||
*
|
||||
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
|
||||
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
|
||||
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
|
||||
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -22,6 +32,8 @@ 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 PROP_ADMIN_BASE_URL_DESC =
|
||||
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
|
||||
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
@@ -30,25 +42,92 @@ public class AdminGatewayClient {
|
||||
/**
|
||||
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||
*
|
||||
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
|
||||
*
|
||||
* @param clientId 차단할 클라이언트 ID
|
||||
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||
* @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");
|
||||
List<String> baseUrls = resolveBaseUrls();
|
||||
|
||||
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
|
||||
RestClientException lastError = null;
|
||||
for (int i = 0; i < baseUrls.size(); i++) {
|
||||
String baseUrl = baseUrls.get(i);
|
||||
String url = baseUrl + CLIENT_BLOCK_PATH;
|
||||
|
||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||
ResponseEntity<Map> response;
|
||||
try {
|
||||
response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||
} catch (RestClientException e) {
|
||||
// 통신 계층 실패 — 다음 admin 주소로 failover
|
||||
lastError = e;
|
||||
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
|
||||
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
|
||||
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 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
|
||||
}
|
||||
|
||||
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||
throw new IllegalStateException(
|
||||
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
|
||||
*
|
||||
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
|
||||
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
|
||||
*/
|
||||
private List<String> resolveBaseUrls() {
|
||||
String raw = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
|
||||
|
||||
List<String> urls = parseBaseUrls(raw);
|
||||
if (urls.isEmpty()) {
|
||||
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
|
||||
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
|
||||
*/
|
||||
static List<String> parseBaseUrls(String raw) {
|
||||
Set<String> ordered = new LinkedHashSet<>();
|
||||
if (raw != null) {
|
||||
for (String token : raw.split("[,\\r\\n]")) {
|
||||
String url = stripTrailingSlashes(token.trim());
|
||||
if (!url.isEmpty()) {
|
||||
ordered.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(ordered);
|
||||
}
|
||||
|
||||
/**
|
||||
* base URL 끝의 {@code '/'} 를 모두 걷어낸다.
|
||||
*
|
||||
* <p>{@code replaceAll("/+$", "")} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||
* 선형 스캔으로 대체했다. 동작은 동일하다.</p>
|
||||
*/
|
||||
private static String stripTrailingSlashes(String url) {
|
||||
int end = url.length();
|
||||
while (end > 0 && url.charAt(end - 1) == '/') {
|
||||
end--;
|
||||
}
|
||||
return url.substring(0, end);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -169,21 +170,9 @@ public class AppServiceFacade {
|
||||
*/
|
||||
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
|
||||
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
|
||||
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||
for (AppRequest r : related) {
|
||||
if (r.getApproval() == null) {
|
||||
continue;
|
||||
}
|
||||
boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||
|| r.getApproval().getApprovalStatus() instanceof ProcessingState;
|
||||
if (!inProgress) {
|
||||
continue;
|
||||
}
|
||||
if (AppRequestType.DELETE.equals(r.getType())) {
|
||||
throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.");
|
||||
}
|
||||
throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
|
||||
String blockReason = resolveDeleteBlockReason(clientId);
|
||||
if (blockReason != null) {
|
||||
throw new IllegalStateException(blockReason);
|
||||
}
|
||||
|
||||
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
|
||||
@@ -203,6 +192,58 @@ public class AppServiceFacade {
|
||||
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)인지 확인합니다.
|
||||
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
|
||||
|
||||
@@ -41,7 +41,13 @@ public class ApprovalService {
|
||||
approval.setApprovalType(ApprovalType.APP);
|
||||
approval.setTargetId(request.getId());
|
||||
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
|
||||
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "] " + request.getType().getDescription() + " 승인");
|
||||
// 동일 법인의 신규/변경/해지 요청이 관리자 목록에서 같은 제목으로 보이면 대상 식별이 불가능하다.
|
||||
// 클라이언트 이름을 제목에 포함해 운영자와 E2E 모두 정확한 승인 건을 검색할 수 있게 한다.
|
||||
String clientName = request.getClientName();
|
||||
String clientNamePart = clientName == null || clientName.trim().isEmpty()
|
||||
? "" : " [" + clientName.trim() + "]";
|
||||
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "]" + clientNamePart
|
||||
+ " " + request.getType().getDescription() + " 승인");
|
||||
|
||||
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
|
||||
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
|
||||
|
||||
@@ -2,8 +2,6 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -12,10 +10,18 @@ import org.springframework.stereotype.Component;
|
||||
* <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>
|
||||
* {@link com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties} 와 동일한
|
||||
* {@code getOrCreateProperty} 패턴을 따른다.</p>
|
||||
*
|
||||
* <p><b>prod 프로파일에서는 DB 값과 무관하게 항상 false</b> 를 반환한다(운영 환경 인증번호 노출 금지).
|
||||
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p>
|
||||
* <p><b>운영(prod)에서도 DB 값만으로 켤 수 있다.</b> 대신 종료일
|
||||
* {@code auth.test-notice.prod-until}({@code yyyy-MM-dd})을 함께 지정해야 하며, 날짜가 지나면
|
||||
* {@link TestNoticeWindow} 가 자동으로 닫는다. 종료일 미설정/형식 오류면 운영에서는 열리지 않는다.
|
||||
* 운영 오픈 전 UMS 실발송 미연계 기간을 위한 한시 스위치다.</p>
|
||||
*
|
||||
* <p>이 판정은 {@link com.eactive.apim.portal.common.breadcrumb.GlobalControllerAdvice} 의
|
||||
* {@code @ModelAttribute} 에서 <b>모든 페이지 요청마다</b> 호출된다. PortalPropertyService 에 캐시가
|
||||
* 없어 호출마다 DB 조회가 발생하므로 {@value #CACHE_TTL_MILLIS}ms 짧은 캐시를 둔다.
|
||||
* PTL_PROPERTY 를 바꾸면 최대 그 시간만큼 반영이 늦다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -23,21 +29,45 @@ public class AuthNoticeProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
|
||||
public static final String KEY_TEST_NOTICE_PROD_UNTIL = "auth.test-notice.prod-until";
|
||||
|
||||
/** 판정 결과 캐시 유효시간(ms) */
|
||||
static final long CACHE_TTL_MILLIS = 30_000L;
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final Environment environment;
|
||||
private final TestNoticeWindow testNoticeWindow;
|
||||
|
||||
private volatile boolean cachedEnabled;
|
||||
/** 캐시 갱신 시각(ms). 0 이면 미조회 */
|
||||
private volatile long cachedAt;
|
||||
|
||||
/**
|
||||
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
|
||||
* prod 환경에서는 property 값과 무관하게 항상 false.
|
||||
* 운영에서는 종료일({@link #KEY_TEST_NOTICE_PROD_UNTIL})까지만 참이 된다.
|
||||
*/
|
||||
public boolean isTestNoticeEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
long now = System.currentTimeMillis();
|
||||
long at = cachedAt;
|
||||
if (at != 0L && now - at < CACHE_TTL_MILLIS) {
|
||||
return cachedEnabled;
|
||||
}
|
||||
boolean enabled = resolveEnabled();
|
||||
cachedEnabled = enabled;
|
||||
cachedAt = now;
|
||||
return enabled;
|
||||
}
|
||||
|
||||
private boolean resolveEnabled() {
|
||||
// 기본값 false - row 가 없는 환경(신규 운영 DB 등)에서 자동으로 켜지지 않도록 한다.
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, KEY_TEST_NOTICE_ENABLED, "false",
|
||||
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
||||
if (value == null || !"true".equalsIgnoreCase(value.trim())) {
|
||||
return false;
|
||||
}
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, KEY_TEST_NOTICE_ENABLED, "true",
|
||||
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||
String until = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
|
||||
"운영(prod)에서 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
|
||||
return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.eactive.apim.portal.apps.auth;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 인증번호 화면 노출(테스트 안내) 스위치의 <b>운영 환경 사용 기한</b> 판정기.
|
||||
*
|
||||
* <p>운영 오픈 전에는 UMS/EAI 실발송 연계가 끝나지 않아 인증번호가 실제로 도달하지 않을 수 있다.
|
||||
* 이 기간에는 prod 에서도 인증번호를 화면에 노출해야 테스트가 가능하다. 다만 스위치 끄기를 잊으면
|
||||
* 인증번호가 상시 노출되므로, prod 에서는 <b>종료일을 명시한 경우에만</b> 열어 주고 그 날짜가 지나면
|
||||
* PTL_PROPERTY 값과 무관하게 자동으로 닫는다.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>비운영(!prod) — 항상 열림. 종료일을 보지 않는다.</li>
|
||||
* <li>운영(prod) — 종료일이 없거나 형식이 잘못되면 <b>닫힘</b>(fail-safe). 종료일 당일까지 열림.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>이메일/휴대폰 인증({@link AuthNoticeProperties})과 2차 인증
|
||||
* ({@code TwoFactorProperties})이 같은 규칙을 쓰도록 판정을 이 클래스로 모은다.
|
||||
* 종료일 프로퍼티 값은 {@code yyyy-MM-dd} 형식이며, 미설정을 뜻하는 기본값은 {@value #UNSET}
|
||||
* ({@code PTL_PROPERTY.property_value} 가 NOT NULL 이라 빈 문자열을 기본값으로 쓸 수 없다).</p>
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class TestNoticeWindow {
|
||||
|
||||
/** 종료일 미설정을 뜻하는 기본값. prod 에서는 이 값이면 닫힘 */
|
||||
public static final String UNSET = "none";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public TestNoticeWindow(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param untilValue 종료일 문자열({@code yyyy-MM-dd}). PTL_PROPERTY 값 원본
|
||||
* @param subject 로그 식별용 스위치 이름
|
||||
* @return 지금 인증번호를 화면에 노출해도 되는 기간인지 여부
|
||||
*/
|
||||
public boolean isOpen(String untilValue, String subject) {
|
||||
if (!environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
LocalDate until = parseDate(untilValue);
|
||||
if (until == null) {
|
||||
return false;
|
||||
}
|
||||
if (LocalDate.now().isAfter(until)) {
|
||||
return false;
|
||||
}
|
||||
// 운영에서 인증번호가 화면에 노출되는 상태 - 흔적을 남긴다.
|
||||
log.warn("운영 환경 인증번호 노출 활성 상태 - {} (종료일 {})", subject, until);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim());
|
||||
} catch (DateTimeParseException e) {
|
||||
log.warn("인증번호 노출 종료일 형식 오류(yyyy-MM-dd 필요) - {}", value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,33 +64,36 @@ public class ApiPermissionFilter {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
|
||||
/**
|
||||
* org 축과 role 축은 각자 독립적으로 선택적 제한이다(비어있으면 그 축은 통과) — 둘을 AND로 합친다.
|
||||
* (과거 OR 합성은 한쪽 축이 비어있으면 그 축이 무조건 true가 되어 다른 쪽 제한을 무력화시키는
|
||||
* 결함이 있었다 — 예: displayOrg 만 설정하고 displayRoleCode 를 비워두면 roleMatch 가 로그인
|
||||
* 여부만으로 true 가 되어 조직 제한이 사실상 적용되지 않았다.)
|
||||
*/
|
||||
static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
|
||||
String org, String roleCode) {
|
||||
if (!isAuthenticated) {
|
||||
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
|
||||
}
|
||||
|
||||
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
|
||||
spec.getDisplayOrg().isEmpty() ||
|
||||
spec.getDisplayOrg().contains(org));
|
||||
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
|
||||
spec.getDisplayRoleCode().isEmpty() ||
|
||||
spec.getDisplayRoleCode().contains(roleCode));
|
||||
return orgMatch || roleMatch;
|
||||
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|
||||
|| (org != null && spec.getDisplayOrg().contains(org));
|
||||
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|
||||
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
|
||||
return orgOk && roleOk;
|
||||
}
|
||||
|
||||
private static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
|
||||
/** {@link #checkServicePermissions} 와 동일 규칙(중복 유지 — 대상 DTO 타입만 다름). */
|
||||
static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
|
||||
String org, String roleCode) {
|
||||
if (!isAuthenticated) {
|
||||
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
|
||||
}
|
||||
|
||||
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
|
||||
spec.getDisplayOrg().isEmpty() ||
|
||||
spec.getDisplayOrg().contains(org));
|
||||
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
|
||||
spec.getDisplayRoleCode().isEmpty() ||
|
||||
spec.getDisplayRoleCode().contains(roleCode));
|
||||
return orgMatch || roleMatch;
|
||||
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|
||||
|| (org != null && spec.getDisplayOrg().contains(org));
|
||||
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|
||||
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
|
||||
return orgOk && roleOk;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +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);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@@ -45,19 +46,31 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime);
|
||||
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,
|
||||
@@ -92,16 +105,26 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
private void validateResendTime(String recipientKey) {
|
||||
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
|
||||
.plusSeconds(resendLimitSeconds).isAfter(now)) {
|
||||
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
|
||||
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
|
||||
.minusSeconds(authNumberExpirationTime)
|
||||
.plusSeconds(resendLimitSeconds);
|
||||
if (resendAvailableAt.isAfter(now)) {
|
||||
long remainingMillis = Duration.between(now, resendAvailableAt).toMillis();
|
||||
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
|
||||
throw new AuthNumberException(
|
||||
String.format("인증번호 재발송 제한이 적용 중입니다. %d초 후 다시 시도해 주세요.",
|
||||
remainingSeconds));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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)) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
|
||||
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker.Outcome;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
@@ -11,6 +13,8 @@ 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.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
/**
|
||||
@@ -30,6 +34,7 @@ public class StepUpPasswordController {
|
||||
|
||||
private final UserFacade userFacade;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
|
||||
|
||||
@GetMapping("/password")
|
||||
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
||||
@@ -44,7 +49,8 @@ public class StepUpPasswordController {
|
||||
@PostMapping("/password")
|
||||
public String verify(@RequestParam String currentPassword,
|
||||
@RequestParam(required = false) String returnUrl,
|
||||
HttpSession session, Model model) {
|
||||
HttpSession session, HttpServletRequest request, HttpServletResponse response,
|
||||
Model model) {
|
||||
String path = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||
return "redirect:/";
|
||||
@@ -52,12 +58,21 @@ public class StepUpPasswordController {
|
||||
|
||||
String loginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
||||
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||
passwordConfirmFailureTracker.reset(session);
|
||||
twoFactorService.grantStepUpPass(session, path);
|
||||
return "redirect:" + path;
|
||||
}
|
||||
|
||||
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
// 연속 실패 카운트 증가. 임계치 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다(무차별 대입 방어).
|
||||
Outcome outcome = passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||
if (outcome.isForcedLogout()) {
|
||||
return outcome.isAccountLocked()
|
||||
? "redirect:/login?pwFailExceeded=1&locked=1"
|
||||
: "redirect:/login?pwFailExceeded=1";
|
||||
}
|
||||
|
||||
model.addAttribute("error", outcome.getMessage());
|
||||
model.addAttribute("returnUrl", path);
|
||||
return "apps/auth/stepupPassword";
|
||||
}
|
||||
|
||||
@@ -41,8 +41,9 @@ public class TwoFactorController {
|
||||
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(session, channel, purpose, force);
|
||||
return twoFactorService.send(request, session, channel, purpose, force);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.TestNoticeWindow;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -25,9 +26,11 @@ public class TwoFactorProperties {
|
||||
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_TEST_NOTICE_PROD_UNTIL = "two-factor.test-notice.prod-until";
|
||||
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final TestNoticeWindow testNoticeWindow;
|
||||
|
||||
/** 로그인 2FA 활성화 여부 */
|
||||
public boolean isLoginEnabled() {
|
||||
@@ -91,9 +94,21 @@ public class TwoFactorProperties {
|
||||
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
||||
}
|
||||
|
||||
/** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */
|
||||
/**
|
||||
* 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용).
|
||||
*
|
||||
* <p>운영(prod)에서는 종료일 {@link #KEY_TEST_NOTICE_PROD_UNTIL}({@code yyyy-MM-dd})을 함께
|
||||
* 지정한 경우에만 참이 되고, 날짜가 지나면 {@link TestNoticeWindow} 가 자동으로 닫는다.
|
||||
* 종료일 미설정/형식 오류면 운영에서는 열리지 않는다. 이메일/휴대폰 인증
|
||||
* ({@code auth.test-notice.*})과 동일한 규칙이다.</p>
|
||||
*/
|
||||
public boolean isTestNoticeEnabled() {
|
||||
return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"));
|
||||
if (!parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"))) {
|
||||
return false;
|
||||
}
|
||||
String until = resolve(KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
|
||||
"운영(prod)에서 2차 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
|
||||
return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED);
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
|
||||
@@ -115,7 +115,8 @@ public class TwoFactorService {
|
||||
// SEND
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorSendResponse send(HttpSession session, String channel, String purpose, boolean force) {
|
||||
public TwoFactorSendResponse send(HttpServletRequest request, HttpSession session, String channel,
|
||||
String purpose, boolean force) {
|
||||
TwoFactorSendResponse res = new TwoFactorSendResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
@@ -156,7 +157,7 @@ public class TwoFactorService {
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
|
||||
return res;
|
||||
}
|
||||
discardContext(session, ctx); // 강제 종료(감사 기록 포함)
|
||||
discardContext(request, session, ctx); // 강제 종료(감사 기록 포함)
|
||||
} else if (ctx.getStartedAt() != null
|
||||
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
|
||||
res.setValid(false);
|
||||
@@ -280,7 +281,7 @@ public class TwoFactorService {
|
||||
String stateError = revalidateLoginState(user);
|
||||
if (stateError != null) {
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.ACCOUNT_DISABLED);
|
||||
LoginFailureReason.ACCOUNT_DISABLED, request.getHeader("User-Agent"));
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
@@ -316,7 +317,8 @@ public class TwoFactorService {
|
||||
|
||||
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);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason,
|
||||
request.getHeader("User-Agent"));
|
||||
}
|
||||
if (ctx != null && ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
@@ -456,10 +458,11 @@ public class TwoFactorService {
|
||||
}
|
||||
|
||||
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
|
||||
private void discardContext(HttpSession session, TwoFactorContext ctx) {
|
||||
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, "-", session.getId(), LoginFailureReason.TWO_FACTOR_CANCELLED);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.TWO_FACTOR_CANCELLED, request.getHeader("User-Agent"));
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
@@ -472,7 +475,8 @@ public class TwoFactorService {
|
||||
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);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason,
|
||||
request.getHeader("User-Agent"));
|
||||
clearPending(session);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.apps.community.partnership.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
||||
import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade;
|
||||
import com.eactive.apim.portal.common.security.WriteRateLimitService;
|
||||
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
import java.io.IOException;
|
||||
@@ -20,10 +22,13 @@ import javax.validation.Valid;
|
||||
public class PartnershipApplicationController {
|
||||
|
||||
private final PartnershipApplicationFacade partnershipApplicationFacade;
|
||||
private final WriteRateLimitService writeRateLimitService;
|
||||
|
||||
@Autowired
|
||||
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade){
|
||||
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade,
|
||||
WriteRateLimitService writeRateLimitService){
|
||||
this.partnershipApplicationFacade = partnershipApplicationFacade;
|
||||
this.writeRateLimitService = writeRateLimitService;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +57,13 @@ public class PartnershipApplicationController {
|
||||
return "apps/community/mainPartnershipForm";
|
||||
}
|
||||
|
||||
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
|
||||
if (writeRateLimitService.isExceeded(WriteTarget.PARTNERSHIP)) {
|
||||
redirectAttributes.addFlashAttribute("error",
|
||||
writeRateLimitService.exceededMessage(WriteTarget.PARTNERSHIP));
|
||||
return "redirect:/partnership";
|
||||
}
|
||||
|
||||
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
||||
|
||||
// 성공 메시지 추가
|
||||
@@ -61,4 +73,23 @@ public class PartnershipApplicationController {
|
||||
return "redirect:/partnership";
|
||||
}
|
||||
|
||||
/**
|
||||
* 본인이 작성한 피드백/개선요청 1건 삭제.
|
||||
* 목록(최근 3건)의 삭제 버튼이 항목별 form 을 POST 한다 — 등록과 동일하게 폼 전송 + flash 메시지 방식.
|
||||
*/
|
||||
@PostMapping("/{id}/delete")
|
||||
public String deleteMyPartnershipApplication(@PathVariable String id, RedirectAttributes redirectAttributes) {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth&redirect=/partnership";
|
||||
}
|
||||
|
||||
try {
|
||||
partnershipApplicationFacade.deleteMyApplication(id);
|
||||
redirectAttributes.addFlashAttribute("success", "피드백/개선요청이 삭제되었습니다.");
|
||||
} catch (IllegalArgumentException e) {
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
}
|
||||
return "redirect:/partnership";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
|
||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Repository
|
||||
@EMSDataSource
|
||||
@@ -16,4 +19,26 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
|
||||
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||
*/
|
||||
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
||||
|
||||
/**
|
||||
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
|
||||
* 등가 조회가 가능한 이유는 위와 동일하다.
|
||||
*/
|
||||
List<PartnershipApplication> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
|
||||
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
|
||||
*/
|
||||
Optional<PartnershipApplication> findByIdAndCreatedBy(String id, String createdBy);
|
||||
|
||||
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
|
||||
@Transactional
|
||||
long deleteByCreatedBy(String createdBy);
|
||||
|
||||
/**
|
||||
* test-cleanup 전용 — 특정 작성자의 글 중 제목이 지정 접두사로 시작하는 것만 조회한다.
|
||||
* (bizSubject 는 암호화 컬럼이 아니라 LIKE 조회가 가능하다.)
|
||||
*/
|
||||
List<PartnershipApplication> findAllByCreatedByAndBizSubjectStartingWith(String createdBy, String bizSubjectPrefix);
|
||||
}
|
||||
|
||||
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
|
||||
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
||||
*/
|
||||
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
||||
|
||||
/**
|
||||
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
|
||||
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
|
||||
*/
|
||||
void deleteMyApplication(String id);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -44,6 +45,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
FileTypeContext.setFileType("business");
|
||||
file = fileService.createFile(partnershipApplicationDTO.getFiles());
|
||||
} finally {
|
||||
FileTypeContext.clear();
|
||||
FileService.clearInternalUserContext();
|
||||
}
|
||||
}
|
||||
@@ -73,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
|
||||
return partnershipApplicationMapper.toSummaryDtoList(recent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteMyApplication(String id) {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("로그인이 필요합니다.");
|
||||
}
|
||||
|
||||
// id 만으로 조회하지 않고 createdBy 를 함께 걸어 타인 글 삭제를 원천 차단한다.
|
||||
PartnershipApplication target = partnershipApplicationService
|
||||
.findOwnedByCreatedBy(id, user.getId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("삭제할 수 있는 피드백/개선요청이 아닙니다."));
|
||||
|
||||
// 첨부파일도 함께 정리한다(관리자 삭제 PortalPartnershipManService.delete 와 동일 처리).
|
||||
if (StringUtils.isNotBlank(target.getFileId())) {
|
||||
fileService.deleteFile(target.getFileId());
|
||||
}
|
||||
partnershipApplicationService.deletePartnershipApplication(target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
|
||||
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
|
||||
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
|
||||
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성자 본인 글 단건 조회. id 와 createdBy 를 함께 조건으로 걸어 타인 글은 조회되지 않는다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<PartnershipApplication> findOwnedByCreatedBy(String id, String createdBy) {
|
||||
return partnershipApplicationRepository.findByIdAndCreatedBy(id, createdBy);
|
||||
}
|
||||
|
||||
public void deletePartnershipApplication(PartnershipApplication partnershipApplication) {
|
||||
partnershipApplicationRepository.delete(partnershipApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.eactive.apim.portal.apps.community.qna.controller;
|
||||
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
|
||||
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
||||
import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
|
||||
import com.eactive.apim.portal.common.security.WriteRateLimitService;
|
||||
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
@@ -37,11 +39,14 @@ public class InquiryController {
|
||||
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
|
||||
private final InquiryFacade inquiryFacade;
|
||||
private final InquiryCommentFacade inquiryCommentFacade;
|
||||
private final WriteRateLimitService writeRateLimitService;
|
||||
|
||||
public InquiryController(InquiryFacade inquiryFacade,
|
||||
InquiryCommentFacade inquiryCommentFacade) {
|
||||
InquiryCommentFacade inquiryCommentFacade,
|
||||
WriteRateLimitService writeRateLimitService) {
|
||||
this.inquiryFacade = inquiryFacade;
|
||||
this.inquiryCommentFacade = inquiryCommentFacade;
|
||||
this.writeRateLimitService = writeRateLimitService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +138,13 @@ public class InquiryController {
|
||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||
}
|
||||
|
||||
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
|
||||
if (writeRateLimitService.isExceeded(WriteTarget.INQUIRY)) {
|
||||
redirectAttributes.addFlashAttribute("error",
|
||||
writeRateLimitService.exceededMessage(WriteTarget.INQUIRY));
|
||||
return REDIRECT_INQUIRY;
|
||||
}
|
||||
|
||||
inquiryFacade.createInquiry(inquiryDTO, image);
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
||||
|
||||
@@ -3,14 +3,29 @@ package com.eactive.apim.portal.apps.community.qna.repository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Repository
|
||||
@EMSDataSource
|
||||
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
|
||||
|
||||
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
|
||||
|
||||
@Transactional
|
||||
long deleteByInquirer_Id(String inquirerId);
|
||||
|
||||
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
|
||||
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
|
||||
|
||||
/**
|
||||
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
|
||||
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||
*/
|
||||
List<Inquiry> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryReposito
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import com.eactive.apim.portal.user.entity.UserLog;
|
||||
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -74,6 +76,7 @@ public class LoginFinalizer {
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final UserLogRepository userLogRepository;
|
||||
|
||||
/**
|
||||
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
|
||||
@@ -92,10 +95,23 @@ public class LoginFinalizer {
|
||||
|
||||
String ip = request.getRemoteAddr();
|
||||
String sessionId = request.getSession().getId();
|
||||
userLogService.logSuccess(rawUsername, ip, sessionId, loginType);
|
||||
// 다른 기기/브라우저의 활성 세션이 남아 있는 상태의 로그인인지(아래 forceLogoutOtherSessions 로
|
||||
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
|
||||
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
|
||||
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
|
||||
// 화면 표시용 "직전 로그인" 조회 — 이번 로그인 기록(logSuccess) 삽입 전에 조회해야
|
||||
// 방금 로그인이 아닌 그 이전 성공 로그인을 얻는다.
|
||||
Optional<UserLog> previousLogin = userLogRepository
|
||||
.findFirstByLoginIdAndSuccessOrderByLoginTimeDesc(rawUsername, true);
|
||||
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
|
||||
duplicate);
|
||||
|
||||
String contextPath = request.getContextPath();
|
||||
HttpSession session = request.getSession();
|
||||
previousLogin.ifPresent(log -> {
|
||||
session.setAttribute("previousLoginDate", log.getLoginTime());
|
||||
session.setAttribute("previousLoginIp", log.getIp());
|
||||
});
|
||||
|
||||
applyPostLoginState(user, session, rawUsername, contextPath);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
|
||||
* 인덱스 페이지 하단 통계 DTO
|
||||
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
|
||||
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
|
||||
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
|
||||
* - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
|
||||
private int activeAppCount;
|
||||
|
||||
/**
|
||||
* API 이용 건수
|
||||
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총 수
|
||||
* API 이용 건수 (월누적)
|
||||
* 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
|
||||
*/
|
||||
private int totalApiCount;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package com.eactive.apim.portal.apps.main.service;
|
||||
|
||||
import com.eactive.apim.portal.app.entity.Credential;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
|
||||
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final CredentialRepository credentialRepository;
|
||||
private final GwAuthClientRepository gwAuthClientRepository;
|
||||
private final ApiStatsDayRepository apiStatsDayRepository;
|
||||
private final ApiStatsHourRepository apiStatsHourRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
// 캐시된 통계 데이터
|
||||
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
|
||||
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
|
||||
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
|
||||
|
||||
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API 수
|
||||
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
|
||||
totalApiCount = activeApps.stream()
|
||||
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
|
||||
.sum();
|
||||
// 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건수
|
||||
List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
|
||||
.filter(id -> id != null && !id.trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
if (!clientIds.isEmpty()) {
|
||||
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
|
||||
}
|
||||
}
|
||||
|
||||
cachedStatistics = IndexStatisticsDTO.builder()
|
||||
@@ -125,6 +136,30 @@ public class IndexStatisticsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게이트웨이 클라이언트들의 이번 달 1일~오늘 누적 API 호출 건수(TOTAL_CNT 합산).
|
||||
* 어제까지는 API_STATS_DAY(일별 집계), 오늘은 아직 DAY 미집계이므로 API_STATS_HOUR로 합산한다.
|
||||
* ({@link com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService#combineDayAndToday}와 동일 패턴)
|
||||
*/
|
||||
private long getMonthlyApiCallCount(List<String> clientIds) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate monthStart = today.withDayOfMonth(1);
|
||||
LocalDate dayEnd = today.minusDays(1);
|
||||
|
||||
long total = 0L;
|
||||
if (!monthStart.isAfter(dayEnd)) {
|
||||
total += nz(apiStatsDayRepository.findSummary(clientIds, monthStart, dayEnd).getTotalCount());
|
||||
}
|
||||
ApiStatisticsSummaryDto todaySummary = apiStatsHourRepository.findSummary(
|
||||
clientIds, today.atStartOfDay(), today.atTime(LocalTime.MAX));
|
||||
total += nz(todaySummary.getTotalCount());
|
||||
return total;
|
||||
}
|
||||
|
||||
private static long nz(Long value) {
|
||||
return value != null ? value : 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* PortalProperty에서 통계 노출 여부 조회
|
||||
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
|
||||
|
||||
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
|
||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||
|
||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||
LocalDate today = LocalDate.now();
|
||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
||||
searchDto.setEndDate(LocalDate.now());
|
||||
searchDto.setStartDate(today.minusDays(7));
|
||||
searchDto.setEndDate(today);
|
||||
searchDto.setClientId(null);
|
||||
|
||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
|
||||
model.addAttribute("details", result.getDetails());
|
||||
model.addAttribute("periods", result.getPeriods());
|
||||
model.addAttribute("searchDto", searchDto);
|
||||
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
|
||||
model.addAttribute("statsMinDate", today.minusYears(1).toString());
|
||||
model.addAttribute("statsMaxDate", today.toString());
|
||||
|
||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
|
||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||
return searchDto.isMonthly()
|
||||
? "조회할 월이 올바르지 않습니다."
|
||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
||||
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
|
||||
+ "일(시작일·종료일 포함)로 선택해주세요.";
|
||||
}
|
||||
|
||||
private PortalOrg getPortalOrg() {
|
||||
|
||||
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
||||
public class ApiStatisticsSearchDto {
|
||||
|
||||
/**
|
||||
* 최대 조회 가능 일수 (일별 모드).
|
||||
* 한 번에 조회 가능한 일수 (일별 모드, 시작일·종료일 포함).
|
||||
*/
|
||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||
|
||||
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
||||
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
|
||||
*/
|
||||
public boolean isValidDateRange() {
|
||||
return isValidDateRange(LocalDate.now());
|
||||
}
|
||||
|
||||
boolean isValidDateRange(LocalDate today) {
|
||||
if (startDate == null || endDate == null) {
|
||||
return false;
|
||||
}
|
||||
if (startDate.isAfter(endDate)) {
|
||||
return false;
|
||||
}
|
||||
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
|
||||
return daysBetween <= MAX_DATE_RANGE_DAYS;
|
||||
LocalDate earliestDate = today.minusYears(1);
|
||||
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
|
||||
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.dto.*;
|
||||
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
@@ -55,6 +56,7 @@ public class AccountController {
|
||||
private final UserSessionService userSessionService;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
|
||||
|
||||
|
||||
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
||||
@@ -65,12 +67,18 @@ public class AccountController {
|
||||
}
|
||||
|
||||
@PostMapping("/password/confirm")
|
||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword,
|
||||
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword);
|
||||
if (userFacade.verifyCurrentPassword(currentLoginId, inputPassword)) {
|
||||
passwordConfirmFailureTracker.reset(session);
|
||||
return ResponseEntity.ok(new ValidationResponse(true, "비밀번호가 확인되었습니다."));
|
||||
}
|
||||
|
||||
String message = isPasswordCorrect ? "비밀번호가 확인되었습니다." : "비밀번호가 일치하지 않습니다.";
|
||||
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
|
||||
// 무제한 시도 차단 — step-up 확인 페이지와 동일한 카운터/정책을 쓴다.
|
||||
PasswordConfirmFailureTracker.Outcome outcome =
|
||||
passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||
return ResponseEntity.ok(new ValidationResponse(false, outcome.getMessage()));
|
||||
}
|
||||
|
||||
@GetMapping("/password/verify")
|
||||
@@ -96,15 +104,29 @@ public class AccountController {
|
||||
}
|
||||
|
||||
@PostMapping("/password/verify")
|
||||
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) {
|
||||
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes,
|
||||
HttpSession session, HttpServletRequest request, HttpServletResponse response, Model model) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
||||
passwordConfirmFailureTracker.reset(session);
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// GET /password/change 와 동일하게 2FA 필요 여부를 내려준다.
|
||||
// 누락되면 폼 JS 가 제출 가로채기(비밀번호 규칙 게이트 + 2FA 팝업)를 등록하지 않아
|
||||
// 서버가 통과권 없음으로 계속 되돌리고 비밀번호를 바꿀 수 없다.
|
||||
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||
return "apps/mypage/passwordChange";
|
||||
} else {
|
||||
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
return "redirect:/password/verify";
|
||||
}
|
||||
|
||||
// 무제한 시도 차단 — 임계 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다.
|
||||
PasswordConfirmFailureTracker.Outcome outcome =
|
||||
passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||
if (outcome.isForcedLogout()) {
|
||||
return outcome.isAccountLocked()
|
||||
? "redirect:/login?pwFailExceeded=1&locked=1"
|
||||
: "redirect:/login?pwFailExceeded=1";
|
||||
}
|
||||
redirectAttributes.addFlashAttribute("error", outcome.getMessage());
|
||||
return "redirect:/password/verify";
|
||||
}
|
||||
|
||||
@PostMapping("/password/change")
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
@@ -22,21 +21,15 @@ 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 java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -64,7 +57,6 @@ public class UserRegisterController {
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
private final PortalProperties portalProperties;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
|
||||
}
|
||||
|
||||
@PostMapping("/check_password_match")
|
||||
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) {
|
||||
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2));
|
||||
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
|
||||
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
|
||||
}
|
||||
|
||||
@PostMapping("/register/confirm_password")
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
|
||||
|
||||
|
||||
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@PasswordMatch(input = "password", confirm = "confirmPassword")
|
||||
@Data
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
public class PortalUserRegistrationDTO {
|
||||
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
|
||||
*/
|
||||
private String password;
|
||||
|
||||
private String password2;
|
||||
|
||||
@CellPhone
|
||||
private String mobileNumber;
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ public class AuthFacadeImpl implements AuthFacade {
|
||||
private final AuthNoticeProperties authNoticeProperties;
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
|
||||
|
||||
/**
|
||||
* 회원가입·아이디/비밀번호 찾기 등 로그인 이전 흐름은 수신자 이름을 알 수 없으므로
|
||||
* 메시지 템플릿 %USER_NAME% 자리에 넣을 기본값.
|
||||
*/
|
||||
private static final String GUEST_USER_NAME = "guest";
|
||||
|
||||
|
||||
/**
|
||||
* 인증 요청
|
||||
@@ -43,7 +49,7 @@ public class AuthFacadeImpl implements AuthFacade {
|
||||
}
|
||||
|
||||
try {
|
||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
|
||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType, GUEST_USER_NAME);
|
||||
response.setValid(true);
|
||||
response.setMessage("인증번호를 발송하였습니다.");
|
||||
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
|
||||
|
||||
@@ -11,8 +11,6 @@ import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.common.exception.SystemException;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||
@@ -20,26 +18,14 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
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.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final ApprovalService approvalService;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(newUser);
|
||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendActivationEmail(newUser);
|
||||
|
||||
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
||||
}
|
||||
|
||||
private void sendActivationEmail(PortalUser newUser) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUsername(newUser.getUserName());
|
||||
recipient.setUserId(newUser.getEmailAddr());
|
||||
recipient.setPhone(newUser.getMobileNumber());
|
||||
HashMap<String, Object> params = new HashMap<>();
|
||||
String tokenValue = newUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + newUser.getId();
|
||||
|
||||
try {
|
||||
String encToken = encryptionUtil.encrypt(tokenValue);
|
||||
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new SystemException("암호화 모듈 오류");
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 사용자를 법인 사용자로 전환하는 메서드
|
||||
private ValidationResponse convertExistingUserToCorporate(
|
||||
PortalUser existingUser,
|
||||
|
||||
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
|
||||
public void withdrawUser(String userId, String withdrawalReason) {
|
||||
PortalUser user = portalUserService.findById(userId);
|
||||
|
||||
// 법인 관리자 탈퇴 제한
|
||||
// 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
||||
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
|
||||
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
|
||||
}
|
||||
throw new IllegalArgumentException(
|
||||
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
|
||||
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.");
|
||||
}
|
||||
|
||||
// 약관 동의 정보 삭제
|
||||
|
||||
@@ -19,7 +19,7 @@ public interface UserRegisterFacade {
|
||||
|
||||
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
|
||||
|
||||
ValidationResponse checkPasswordMatch(String password, String password2);
|
||||
ValidationResponse checkPasswordMatch(String password, String confirmPassword);
|
||||
|
||||
ValidationResponse verifyPassword(String loginId, String confirmPassword);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.facade;
|
||||
|
||||
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberGenerator;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
@@ -11,15 +10,11 @@ import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
private final PasswordValidator passwordValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AuthNumberGenerator authNumberGenerator;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -94,8 +85,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ValidationResponse checkPasswordMatch(String password, String password2) {
|
||||
boolean isMatch = password.equals(password2);
|
||||
public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
|
||||
boolean isMatch = password.equals(confirmPassword);
|
||||
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
|
||||
return new ValidationResponse(isMatch, message);
|
||||
}
|
||||
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendEmailActivation(newUser);
|
||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||
}
|
||||
|
||||
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void sendEmailActivation(PortalUser registeredUser) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUsername(registeredUser.getUserName());
|
||||
recipient.setUserId(registeredUser.getEmailAddr());
|
||||
recipient.setPhone(registeredUser.getMobileNumber());
|
||||
|
||||
// 25.10.01 - 이메일 링크 방식으로 접근 불가이기에 SMS 인증방식과 동일하게 대체
|
||||
HashMap<String, Object> params = new HashMap<>();
|
||||
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
|
||||
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
|
||||
params.put("token", tokenValue);
|
||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
||||
|
||||
// try {
|
||||
// String encToken = encryptionUtil.encrypt(tokenValue);
|
||||
// params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
||||
// messageHandlerService.publishEvent(UserEmailActivationEvent.KEY, recipient, params);
|
||||
// } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
// throw new SystemException("암호화 모듈 오류");
|
||||
// }
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
||||
|
||||
@@ -34,7 +34,8 @@ public class PasswordService {
|
||||
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
|
||||
|
||||
validatePasswordUpdate(user, newPassword, confirmPassword);
|
||||
checkPasswordHistory(user.getLoginId(), newPassword);
|
||||
// PTL_USER_PASSWORD_HISTORY.USER_ID 에는 loginId가 아닌 PortalUser.id가 저장된다.
|
||||
checkPasswordHistory(user.getId(), newPassword);
|
||||
|
||||
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
|
||||
if(histories.isEmpty()) {
|
||||
@@ -85,6 +86,17 @@ public class PasswordService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로
|
||||
* 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 <b>덮어쓰기 직전</b>에 호출해,
|
||||
* 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를
|
||||
* 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다.
|
||||
*/
|
||||
@Transactional
|
||||
public void recordExternalPasswordChange(String userId, String previousPasswordHash) {
|
||||
savePasswordHistory(userId, previousPasswordHash);
|
||||
}
|
||||
|
||||
private void checkPasswordHistory(String userId, String newPassword) {
|
||||
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
||||
|
||||
@@ -115,26 +127,4 @@ public class PasswordService {
|
||||
|
||||
passwordHistoryRepository.save(newHistory);
|
||||
}
|
||||
|
||||
private boolean isValidPassword(String password) {
|
||||
if (password.length() < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasLetter = false;
|
||||
boolean hasDigit = false;
|
||||
boolean hasSpecial = false;
|
||||
|
||||
for (char c : password.toCharArray()) {
|
||||
if (Character.isLetter(c)) {
|
||||
hasLetter = true;
|
||||
} else if (Character.isDigit(c)) {
|
||||
hasDigit = true;
|
||||
} else if (!Character.isWhitespace(c)) {
|
||||
hasSpecial = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasLetter && hasDigit && hasSpecial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,23 +16,14 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
@@ -54,8 +45,8 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
private final PasswordService passwordService;
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = UsernameNotFoundException.class)
|
||||
@@ -66,7 +57,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
||||
return buildAuthenticatedUser(portalUser);
|
||||
} catch (UserNotFoundException e) {
|
||||
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||
// 계정 열거(user enumeration) 공격 방지: 비밀번호 불일치(BadCredentialsException, PortalAuthenticationManager)와
|
||||
// 동일한 문구를 사용해 아이디 존재 여부가 노출되지 않도록 한다.
|
||||
throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +155,10 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
|
||||
|
||||
String tempPassword = EncryptionUtil.generateNewPassword();
|
||||
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
|
||||
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
|
||||
// 것이 허용되는 보안 허점이 생긴다.
|
||||
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
|
||||
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
||||
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
||||
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
||||
|
||||
@@ -12,17 +12,22 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional
|
||||
public class PortalUserLogService {
|
||||
|
||||
/** PTL_USER_LOG.USER_AGENT 컬럼 길이. 초과분은 잘라서 저장한다(초과 시 ORA-12899 로 로그인 실패). */
|
||||
private static final int USER_AGENT_MAX_LENGTH = 500;
|
||||
|
||||
private final UserLogRepository userLogRepository;
|
||||
|
||||
public PortalUserLogService(UserLogRepository userLogRepository) {
|
||||
this.userLogRepository = userLogRepository;
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId) {
|
||||
logSuccess(userId, ip, sessionId, LoginType.NORMAL);
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType) {
|
||||
/**
|
||||
* 로그인 성공 기록.
|
||||
*
|
||||
* @param duplicate 로그인 시점에 다른 활성 세션이 남아 있어 기존 세션이 강제 해제된 건이면 true
|
||||
*/
|
||||
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType, String userAgent,
|
||||
boolean duplicate) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
@@ -30,15 +35,13 @@ public class PortalUserLogService {
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(true);
|
||||
log.setLoginType(loginType != null ? loginType.name() : null);
|
||||
log.setUserAgent(truncateUserAgent(userAgent));
|
||||
log.setDuplicate(duplicate);
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId) {
|
||||
logFailure(userId, ip, sessionId, LoginFailureReason.UNKNOWN);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason) {
|
||||
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason, String userAgent) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
@@ -46,19 +49,29 @@ public class PortalUserLogService {
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(false);
|
||||
log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name());
|
||||
log.setUserAgent(truncateUserAgent(userAgent));
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void logLogout(String userId, String ip, String sessionId) {
|
||||
public void logLogout(String userId, String ip, String sessionId, String userAgent) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLogoutTime(LocalDateTime.now());
|
||||
log.setIp(ip);
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(true);
|
||||
log.setUserAgent(truncateUserAgent(userAgent));
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
private String truncateUserAgent(String userAgent) {
|
||||
if (userAgent == null) {
|
||||
return null;
|
||||
}
|
||||
return userAgent.length() <= USER_AGENT_MAX_LENGTH
|
||||
? userAgent : userAgent.substring(0, USER_AGENT_MAX_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,23 @@
|
||||
package com.eactive.apim.portal.apps.user.validator;
|
||||
|
||||
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 비밀번호 문자열 정책 검사 진입점. 규칙 자체는 정본인 {@link PasswordRuleValidator} 가 갖고,
|
||||
* 여기서는 DB 토글({@link PasswordPolicyProperties})을 얹어 위임만 한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PasswordValidator {
|
||||
|
||||
public boolean isValidPassword(String password) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String tmpPw = password.toUpperCase();
|
||||
return isValidLengthAndCharacters(tmpPw) && !containsInvalidPatterns(tmpPw);
|
||||
}
|
||||
private final PasswordPolicyProperties passwordPolicyProperties;
|
||||
|
||||
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
||||
PasswordRuleValidator validator = new PasswordRuleValidator();
|
||||
return validator.isValid(password, loginId, mobileNumber);
|
||||
}
|
||||
|
||||
private boolean isValidLengthAndCharacters(String password) {
|
||||
final int MIN = 8;
|
||||
final int MAX = 50;
|
||||
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
|
||||
return password.matches(REGEX);
|
||||
}
|
||||
|
||||
private boolean containsInvalidPatterns(String password) {
|
||||
final String SAMEPT = "(\\w)\\1\\1";
|
||||
final String BLANKPT = "(\\s)";
|
||||
|
||||
if (password.matches(BLANKPT) || password.matches(SAMEPT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return containsContinuousCharacters(password);
|
||||
}
|
||||
|
||||
private boolean containsContinuousCharacters(String password) {
|
||||
for (int i = 0; i < password.length() - 2; i++) {
|
||||
if (isContinuous(password.charAt(i), password.charAt(i + 1), password.charAt(i + 2))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isContinuous(char a, char b, char c) {
|
||||
return (b - a == 1) && (c - b == 1);
|
||||
return validator.isValid(password, loginId, mobileNumber,
|
||||
passwordPolicyProperties.isKeyboardSequenceBlocked());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSite;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
||||
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
|
||||
@ControllerAdvice
|
||||
@@ -29,15 +34,27 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private ClientGuardService clientGuardService;
|
||||
|
||||
@Autowired
|
||||
private PasswordPolicyProperties passwordPolicyProperties;
|
||||
|
||||
@Autowired
|
||||
private PortalPropertyService portalPropertyService;
|
||||
|
||||
@Autowired
|
||||
private AuthNoticeProperties authNoticeProperties;
|
||||
|
||||
@Autowired
|
||||
private RelatedSiteService relatedSiteService;
|
||||
|
||||
@Autowired
|
||||
private GuideProperty guideProperty;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@Autowired
|
||||
private PasswordCryptoProperties passwordCryptoProperties;
|
||||
|
||||
@ModelAttribute("breadcrumb")
|
||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||
String currentPath = request.getRequestURI();
|
||||
@@ -102,6 +119,47 @@ public class GlobalControllerAdvice {
|
||||
return clientGuardService.isDevtoolsGuardEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 정책 - 키보드 배열 연속 문자 차단 여부.
|
||||
* PortalProperty(Portal/password.keyboard-sequence.block)에서 조회.
|
||||
* head 의 window.__PASSWORD_POLICY__ 와 비밀번호 요구사항 체크리스트 노출에 함께 쓴다.
|
||||
*/
|
||||
@ModelAttribute("passwordKeyboardSequenceBlock")
|
||||
public boolean passwordKeyboardSequenceBlock() {
|
||||
return passwordPolicyProperties.isKeyboardSequenceBlocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화 설정(yml {@code portal.security.password-encrypt.*}).
|
||||
* head 의 {@code window.__PASSWORD_CRYPTO__} 로 내려가 password-crypto.js 가 읽는다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code enabled} - 꺼져 있으면 화면은 아무것도 하지 않고 평문 전송한다.</li>
|
||||
* <li>{@code policy} - 평문 비밀번호 처리 정책. 화면은 경고 팝업 노출 여부·문구를 여기서 정한다.</li>
|
||||
* <li>{@code softwareFallback} - {@code crypto.subtle} 이 없을 때 forge 번들을 내려받아 쓸지.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ModelAttribute("passwordCrypto")
|
||||
public Map<String, Object> passwordCrypto() {
|
||||
Map<String, Object> config = new java.util.LinkedHashMap<>();
|
||||
config.put("enabled", passwordCryptoProperties.isEnabled());
|
||||
config.put("policy", passwordCryptoProperties.getPlaintextPolicy().name());
|
||||
config.put("softwareFallback", passwordCryptoProperties.isSoftwareFallback());
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||
*/
|
||||
@ModelAttribute("activeProfileBadge")
|
||||
public String activeProfileBadge() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return null;
|
||||
}
|
||||
String[] activeProfiles = environment.getActiveProfiles();
|
||||
return activeProfiles.length == 0 ? "default" : String.join(", ", activeProfiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
|
||||
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
|
||||
@@ -111,4 +169,91 @@ public class GlobalControllerAdvice {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 페이지 본문에 노출되는 브랜드명. PortalProperty(Portal/brand.name)에서 조회.
|
||||
* 로고 이미지(alt 텍스트)는 별도이며 이 값의 영향을 받지 않는다.
|
||||
*/
|
||||
@ModelAttribute("brandName")
|
||||
public String brandName() {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "brand.name", "DJBank", "메인 페이지 브랜드명 표기");
|
||||
}
|
||||
|
||||
/**
|
||||
* brandName 뒤에 바로 붙는 주격 조사(이/가). 받침 유무에 따라 관리자가 값을 바꿔도 문법이 깨지지 않도록 계산한다.
|
||||
*/
|
||||
@ModelAttribute("brandNameJosaGa")
|
||||
public String brandNameJosaGa() {
|
||||
return hasBatchim(brandName()) ? "이" : "가";
|
||||
}
|
||||
|
||||
/**
|
||||
* brandName 뒤에 바로 붙는 보조사(은/는).
|
||||
*/
|
||||
@ModelAttribute("brandNameJosaEun")
|
||||
public String brandNameJosaEun() {
|
||||
return hasBatchim(brandName()) ? "은" : "는";
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더(GNB) 로고 이미지 경로. PortalProperty(Portal/brand.logo.header.path)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("brandLogoHeaderPath")
|
||||
public String brandLogoHeaderPath() {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "brand.logo.header.path", "/img/logo/logo-djb.png", "헤더(GNB) 로고 이미지 경로");
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 로고 이미지 경로. PortalProperty(Portal/brand.logo.footer.path)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("brandLogoFooterPath")
|
||||
public String brandLogoFooterPath() {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "brand.logo.footer.path", "/img/logo/logo-jjb.png", "푸터 로고 이미지 경로");
|
||||
}
|
||||
|
||||
private boolean hasBatchim(String word) {
|
||||
if (word == null || word.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
char last = word.charAt(word.length() - 1);
|
||||
if (last >= 0xAC00 && last <= 0xD7A3) {
|
||||
return (last - 0xAC00) % 28 != 0;
|
||||
}
|
||||
return "AEIOUaeiou".indexOf(last) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("relatedSitesLabel")
|
||||
public String relatedSitesLabel() {
|
||||
return relatedSiteService.getLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 관련 사이트 목록. PortalProperty(Portal/footer.related-sites)의 '이름=URL' 줄 목록을 파싱한 결과.
|
||||
* 비어 있으면 푸터에서 셀렉트 자체를 렌더하지 않는다.
|
||||
*/
|
||||
@ModelAttribute("relatedSites")
|
||||
public List<RelatedSite> relatedSites() {
|
||||
return relatedSiteService.getSites();
|
||||
}
|
||||
|
||||
/**
|
||||
* 개발가이드 안내페이지에 표기할 게이트웨이 base URL.
|
||||
* PortalProperty(Portal/djb.guide.gw-url)에서 조회하며, 환경별로 값이 달라진다.
|
||||
*
|
||||
* <p>가이드 페이지(/service/*-guide)에서만 필요하므로 다른 요청에서는 DB 조회 없이 null 을 반환한다.
|
||||
*/
|
||||
@ModelAttribute("guideGwUrl")
|
||||
public String guideGwUrl(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null || !uri.endsWith("-guide")) {
|
||||
return null;
|
||||
}
|
||||
return guideProperty.gatewayUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
@@ -36,23 +35,27 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
@RequiredArgsConstructor
|
||||
public class PortalGlobalExceptionHandler {
|
||||
|
||||
private static final String REDIRECT_PREFIX = "redirect:";
|
||||
private static final String REDIRECT_HOME = REDIRECT_PREFIX + "/";
|
||||
private static final String REDIRECT_LOGIN_AUTH = REDIRECT_PREFIX + "/login?reason=auth";
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(getClass());
|
||||
private final PortalProperties portalProperties;
|
||||
private final Environment environment;
|
||||
|
||||
@ExceptionHandler(value = NotFoundException.class)
|
||||
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
|
||||
return new ModelAndView("redirect:/");
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = MethodArgumentTypeMismatchException.class)
|
||||
public ModelAndView handleMethodArgumentTypeMismatchException(HttpServletRequest request, MethodArgumentTypeMismatchException ex) {
|
||||
return new ModelAndView("redirect:/");
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = UserNotLoginException.class)
|
||||
public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) {
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
return new ModelAndView(REDIRECT_LOGIN_AUTH);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = AccessDeniedException.class)
|
||||
@@ -61,7 +64,7 @@ public class PortalGlobalExceptionHandler {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
|
||||
savePostLoginRedirect(request);
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
return new ModelAndView(REDIRECT_LOGIN_AUTH);
|
||||
}
|
||||
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
@@ -122,9 +125,40 @@ public class PortalGlobalExceptionHandler {
|
||||
|| path.startsWith("/favicon"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 리다이렉트/뷰 이름으로 허용할 문자. 스킴 구분자(:)·중괄호·달러를 막아
|
||||
* 외부 URL 이나 Thymeleaf 표현식이 뷰 이름으로 흘러드는 것을 차단한다.
|
||||
*/
|
||||
private static final Pattern SAFE_REDIRECT_TARGET = Pattern.compile("[A-Za-z0-9._/?=&%-]+");
|
||||
|
||||
@ExceptionHandler(value = PortalRedirectException.class)
|
||||
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
|
||||
return new ModelAndView(ex.getMessage());
|
||||
// PortalRedirectException 은 super(message) 를 호출하지 않으므로 getMessage() 는 항상 null 이다.
|
||||
// 이전 구현은 그 null 을 뷰 이름으로 넘겨(= 뷰 미지정) 요청 URL 기준으로 뷰가 추론되게 만들었다.
|
||||
// 실제 대상은 redirectPage 필드다.
|
||||
String target = ex.getRedirectPage();
|
||||
if (!isSafeRedirectTarget(target)) {
|
||||
log.warn("허용되지 않은 리다이렉트 대상 - uri={}, target={}", request.getRequestURI(), target);
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
return new ModelAndView(target);
|
||||
}
|
||||
|
||||
/** 내부 경로/뷰 이름만 허용(외부 URL·프로토콜 상대 URL·표현식 문자 차단) */
|
||||
private boolean isSafeRedirectTarget(String target) {
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
String path = target;
|
||||
if (path.startsWith(REDIRECT_PREFIX)) {
|
||||
path = path.substring(REDIRECT_PREFIX.length());
|
||||
} else if (path.startsWith("forward:")) {
|
||||
path = path.substring("forward:".length());
|
||||
}
|
||||
if (path.isEmpty() || path.startsWith("//")) { // //evil.example 형태의 외부 리다이렉트 차단
|
||||
return false;
|
||||
}
|
||||
return SAFE_REDIRECT_TARGET.matcher(path).matches();
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = {IllegalArgumentException.class})
|
||||
@@ -140,7 +174,7 @@ public class PortalGlobalExceptionHandler {
|
||||
public ModelAndView handleHttpRequestMethodNotSupportedException(HttpServletRequest request, HttpRequestMethodNotSupportedException ex) {
|
||||
log.error(ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.setViewName("redirect:/");
|
||||
modelAndView.setViewName(REDIRECT_HOME);
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -159,10 +193,9 @@ public class PortalGlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(value = Exception.class)
|
||||
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
||||
String requestParams = request.getParameterMap().entrySet()
|
||||
.stream()
|
||||
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
|
||||
.collect(Collectors.joining(", "));
|
||||
// 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
|
||||
// 이 시점의 파라미터는 이미 복호화된 평문이다.
|
||||
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
|
||||
|
||||
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
||||
|
||||
@@ -184,7 +217,7 @@ public class PortalGlobalExceptionHandler {
|
||||
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
redirectAttributes.addFlashAttribute("error", ex.getMessage());
|
||||
modelAndView.setViewName("redirect:" + request.getRequestURI());
|
||||
modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -195,7 +228,7 @@ public class PortalGlobalExceptionHandler {
|
||||
String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다.";
|
||||
redirectAttributes.addFlashAttribute("error", errorMessage);
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.setViewName("redirect:" + request.getRequestURI());
|
||||
modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
|
||||
return modelAndView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
/**
|
||||
* 작성 요청이 허용 빈도를 초과했을 때 던진다(무제한 요청 차단).
|
||||
*
|
||||
* <p>JSON 응답 컨트롤러에서만 사용한다 — 폼 전송 컨트롤러는 예외 대신
|
||||
* flash 메시지로 되돌려야 하므로 {@code isExceeded} 판정을 직접 쓴다.</p>
|
||||
*/
|
||||
public class TooManyWriteRequestsException extends RuntimeException {
|
||||
|
||||
public TooManyWriteRequestsException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
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.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||
*
|
||||
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
|
||||
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
|
||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||
*
|
||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* # 미리보기(변경 안 함)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||
* # 실제 실행 (PII 컬럼)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
||||
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/internal/migration")
|
||||
public class LegacyEncryptionMigrationController {
|
||||
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
|
||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
|
||||
);
|
||||
|
||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||
private static final List<String> AUDIT_TABLES = Arrays.asList(
|
||||
"PTL_USER", "ptl_faq", "ptl_org",
|
||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
|
||||
);
|
||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
|
||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||
}
|
||||
|
||||
@PostMapping("/encrypt-legacy")
|
||||
@Transactional("transactionManager")
|
||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||
assertLocalOnly(request);
|
||||
assertNotBypass();
|
||||
|
||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||
if (includeAudit) {
|
||||
for (String table : AUDIT_TABLES) {
|
||||
targets.add(new TargetTable(table, AUDIT_COLUMNS));
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int totalChanged = 0;
|
||||
for (TargetTable target : targets) {
|
||||
for (String column : target.columns) {
|
||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||
results.add(r);
|
||||
totalChanged += (int) r.get("changed");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||
response.put("includeAudit", includeAudit);
|
||||
response.put("totalChanged", totalChanged);
|
||||
response.put("results", results);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||
*/
|
||||
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("table", table);
|
||||
r.put("column", column);
|
||||
|
||||
List<String> values;
|
||||
try {
|
||||
values = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
|
||||
String.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||
r.put("distinct", 0);
|
||||
r.put("changed", 0);
|
||||
r.put("error", e.getMessage());
|
||||
return r;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
for (String value : values) {
|
||||
String normalized;
|
||||
try {
|
||||
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
|
||||
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||
continue;
|
||||
}
|
||||
if (normalized != null && !normalized.equals(value)) {
|
||||
if (!dryRun) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
|
||||
normalized, value);
|
||||
}
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
r.put("distinct", values.size());
|
||||
r.put("changed", changed);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
|
||||
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
|
||||
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
|
||||
*/
|
||||
private void assertNotBypass() {
|
||||
if (converter.isBypassMode()) {
|
||||
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
|
||||
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
*/
|
||||
private void assertLocalOnly(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
boolean localAddr = "127.0.0.1".equals(remote)
|
||||
|| "0:0:0:0:0:0:0:1".equals(remote)
|
||||
|| "::1".equals(remote);
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TargetTable {
|
||||
final String table;
|
||||
final List<String> columns;
|
||||
|
||||
TargetTable(String table, List<String> columns) {
|
||||
this.table = table;
|
||||
this.columns = columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.eactive.apim.portal.common.security;
|
||||
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.groovy.util.Maps;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 계정 잠금 처리 단일 지점.
|
||||
*
|
||||
* <p>로그인 연속 실패(`PortalAuthenticationFailureHandler`)와 본인확인 연속 실패
|
||||
* (`PasswordConfirmFailureTracker`)가 같은 방식으로 계정을 잠그도록 로직을 모았다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccountLockService {
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
|
||||
/**
|
||||
* 계정을 잠그고 잠금 알림을 발송한다. 이미 잠긴 계정이면 알림을 중복 발송하지 않는다.
|
||||
*
|
||||
* <p>호출자의 트랜잭션에 참여한다 — 로그인 실패 핸들러는 이미 자신만의
|
||||
* {@code REQUIRES_NEW} 트랜잭션에서 실행되므로 별도 전파를 두면 같은 row 를 두 트랜잭션이
|
||||
* 잠그게 된다.</p>
|
||||
*
|
||||
* @param reason 알림 문구에 실을 잠금 사유
|
||||
*/
|
||||
@Transactional
|
||||
public void lock(PortalUser user, String reason) {
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
if ("Y".equals(user.getAccountLockYn())) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.setAccountLockYn("Y");
|
||||
portalUserRepository.save(user);
|
||||
messageHandlerService.publishEvent(MessageCode.USER_ACCOUNT_LOCKED,
|
||||
MessageRecipient.of(user), Maps.of("reason", reason));
|
||||
log.warn("계정 잠금 처리 userId={} reason={}", user.getId(), reason);
|
||||
}
|
||||
}
|
||||