41 Commits

Author SHA1 Message Date
Rinjae f1d6181210 - Playwright 테스트 정리용 internal API 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- /internal/test-cleanup/{org,user,orphans} 신설
- dev 프로필 전용, 토큰+IP allowlist 이중 가드
- 13개 고아 테이블 cascade/스윕 삭제 서비스
- 리포지토리 deleteBy 메서드 추가, 단위테스트 포함
2026-08-21 17:57:51 +09:00
Rinjae bac9a54dee 활성 프로파일 뱃지 추가 - HTTP 세션 로그 설정 제거 - 개인정보 암호화 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-21 17:25:41 +09:00
Rinjae 603cba65a7 API 목록 페이징 기능 추가 - 검색 결과 페이지 처리 로직 구현
API 아이콘 스트리밍 엔드포인트 추가 - base64 데이터 URL 캐싱 처리
response 압축 설정 추가 - application.yml에 설정 값 정의
2026-08-21 10:48:47 +09:00
Rinjae 889d77b990 로그 설정 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- JVM 파라미터로 파일, 세션, API 테스터 로그 레벨 제어 추가
- 루트, 세션, API 테스터 로그 레벨 동적 설정 지원
2026-08-20 14:34:01 +09:00
Rinjae bf2dad6ba7 고객센터 연락처 미노출 조건 변경 - 문자열 공백 검사 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 18:47:54 +09:00
Rinjae 3a1b0cb9c1 고객센터 연락처 미노출 조건 변경 - 문자열 공백 검사 함수 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 18:25:19 +09:00
Rinjae a13279a777 고객센터 연락처 미노출 조건 추가 - 값 공백 시 th:if로 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 16:46:58 +09:00
Rinjae 5299352235 CSS hover 색상값 표현 변경 (RGB → 퍼센트 형식)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 15:17:41 +09:00
Rinjae 8b4bc425ec - build.gradle: damo-manager 의존성 scope 변경(compileOnly → implementation)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- application.yml: 로그 경로 수정(/logs/eapim → /logs/prod/eapim)
2026-08-14 19:05:29 +09:00
Rinjae 06dc148515 Lodash 업데이트 및 보안 강화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Lodash 버전 4.17.21 → 4.18.1로 업데이트
- prototype pollution 방지 로직 추가
- `_.template` 관련 보안 경고 및 문서 갱신
2026-08-14 17:48:45 +09:00
Rinjae 3cfd6b0b04 Atomikos JTA 제거: 로컬 트랜잭션 전환 및 관련 설정 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- JTA/XA 미사용으로 변경: JpaTransactionManager로 전환
- Atomikos 의존성 및 설정 제거, 관련 문서 및 주석 업데이트
2026-08-14 17:11:54 +09:00
Rinjae 7a0f88bfb1 Atomikos 설정 변경: max-actives 속성 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-14 16:50:10 +09:00
Rinjae 6d7b292b8a 운영 인증번호 테스트 종료일 판정 클래스 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- TestNoticeWindow: 운영 환경에서 인증번호 노출 종료일 유효성 검증
- TwoFactorProperties/AuthNoticeProperties에 종료일 기반 처리 로직 통합
2026-08-14 13:13:20 +09:00
Rinjae 20ad6f8394 Thymeleaf 템플릿 JS 분석 제외 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- sonar.javascript.exclusions 속성에 템플릿 경로 추가
- XSS 룰 유지 위해 HTML 분석은 제외하지 않음
2026-08-13 18:18:09 +09:00
Rinjae 2bae0e350c OWASP Dependency-Check 통합 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Jenkinsfile.security: 라이브러리 취약점 검사용 파이프라인 작성 (SAST/SCA 병행)
- dependency-check-classpath.gradle: 스캔 대상 의존성 jar 분리 로직 작성
- dependency-check-suppressions.xml: 오탐 억제 규칙 파일 추가
2026-08-13 17:53:14 +09:00
Rinjae 45e8fa2903 레거시 암호화 컨트롤러 제거 - 내부 API 토큰 초기화 로직 추가 - 정규식 성능 최적화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-13 16:52:04 +09:00
Rinjae 0b6ee10439 Jenkinsfile.sonar: SonarQube 설정 이름 보정 및 스캐너 탐색 폴백
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- SONAR_ENV_NAME 을 실제 등록명 SonarQube-Community 로 수정
- 스캐너 툴 이름 미지정 시 후보 이름 탐색 후 PATH 의 sonar-scanner 폴백
- 스캐너를 못 찾으면 원인이 드러나는 메시지와 함께 실패

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:35:12 +09:00
Rinjae 1a95f015dc Jenkinsfile.sonar: 컴파일 전 생성 소스 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
clean 이 build/generated 잠금으로 실패하는 사례가 있어 rm -rf 를 선행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:32:15 +09:00
Rinjae 92589530d1 Merge branch 'master' into feats/ci-test
# Conflicts:
#	Jenkinsfile.deploy
#	Jenkinsfile.test-build
#	src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java
2026-08-13 15:30:31 +09:00
Rinjae be40973aa3 SonarQube 정적 보안 분석 파이프라인 추가
- Jenkinsfile.sonar: 분석 전용 파이프라인 (컴파일 JDK8 / 스캐너 JDK17 분리,
  Quality Gate 실패는 UNSTABLE 처리, 토큰은 환경변수로만 전달)
- sonar-project.properties: 분석 대상/제외 규칙, Java 8 소스 설정
- ci/sonar-classpath.gradle: build.gradle 무수정으로 sonar.java.libraries 덤프
  (타입 해석용 컴파일 클래스패스가 없으면 보안 룰 다수가 동작하지 않음)
- .gitignore: .scannerwork/ 제외

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:29:21 +09:00
Rinjae 1e21b85de1 - 소수 자릿수 처리 개선 - DECIMAL/BIGDECIMAL 정수/숫자 구분
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 샘플 생성 로직 리팩토링 - 길이 및 소수 자릿수 기준 상세화
- 공통 스키마 처리 함수 추가 - 코드 중복 제거 및 가독성 향상
2026-08-13 14:42:44 +09:00
Rinjae 7b8e0575c5 Actuator 인터셉터 엔드포인트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `/actuator/mappings`에 누락된 Interceptor 목록 읽기용 엔드포인트 구현
- 내부 사용자 이메일 도메인 동적 주입 로직 UserTypeConfigurer 클래스에 추가
- 비동기 스타일 개선 및 CSS 메인 스타일 일부 수정
2026-08-13 13:36:09 +09:00
Rinjae 7c0cd371f0 클라이언트 표현 수정 및 내부 사용자 이메일 도메인 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- "앱" -> "클라이언트"로 명칭 일괄 수정 (HTML/DTO/JS)
- 내부 사용자 이메일 도메인 동적 설정 로직 및 기본값 추가
2026-08-13 12:50:50 +09:00
Rinjae eb2cce8002 Actuator/SBA 로컬 제한 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- application.yml 주석 및 build.gradle 로컬 전용 라이브러리 처리
- 배포 산출물에서 로컬 전용 리소스/라이브러리 제외
2026-08-12 11:48:37 +09:00
Rinjae 634c97c7ca 첨부파일 업로드 제약 조건 추가 및 APIM 서비스 파일 컨텍스트 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 첨부파일 업로드 시 파일 크기/형식 조건 동적 적용
- FileTypeContext.clear 호출 추가, 내부 사용자 컨텍스트 정리
2026-08-12 09:41:59 +09:00
Rinjae e32acfa215 UserLog 엔티티 필드 추가 및 관련 API User-Agent 처리 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- UserLog에 userAgent(500자)와 duplicate(Boolean) 컬럼 추가
- 로그인/실패/로그아웃 로깅 시 User-Agent와 중복 세션 여부 기록
- TwoFactorService/Controller Request 객체 전달 및 처리 반영
2026-08-11 19:10:39 +09:00
Rinjae 8356fc3029 - Swagger spec 서버 주소 GW 기준 치환 통합 - UI/다운로드 동일 기준
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- ApiTesterFilter: gw 호출 시 base-url 재조립 처리 추가
- admin 스펙 저장 시 실주소 고정 제거 - 재치환 유연성 확보
2026-08-11 19:00:18 +09:00
Rinjae d6186de009 DEFAULT spec 서버 치환 제거:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GW/sentinel 치환 로직 삭제 및 serverRewriter 호출 제거
- mock path 분리/servers 덮어쓰기 로직 및 공통 유틸 추가
2026-08-11 16:56:17 +09:00
Rinjae 2a9980ab16 - API Tester 프록시 응답 헤더 전달 로직 개선 - BLOCKED 헤더 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- API ID 기반 명세 조회 기능 추가 - GW/Mock path 매칭 문제 해결
- APISender에 ApiResponse 클래스 도입 - 응답 상태코드/본문/헤더 반환
2026-08-11 16:38:46 +09:00
Rinjae 919b4a0b06 이메일 인증 템플릿 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 제주은행/DJ Bank 이메일 템플릿 신규 추가
- Base64 임베디드 이미지 및 링크형 리소스 분리
2026-08-11 15:03:37 +09:00
Rinjae b0ecb26a9d 변경/해지 신청 개선 및 사전검사 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 변경/해지 상태 표시 및 취소 기능 추가
- 해지 신청 사전검사 및 경고 메시지 처리 구현
- 관리자 승인 이전 허용되는 취소 정책 지원
2026-08-11 14:15:33 +09:00
Rinjae 0ad5bffa4f DJB 커스텀 소스 납품 스크립트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Windows, Linux/macOS 지원 배치/셸 스크립트 구현
- 소스 필터 규칙 및 수집 파일/모듈 매니페스트 작성
2026-08-11 11:47:31 +09:00
Rinjae 766341540d 개발가이드 Base URL 동적 처리, Mock 응답 인증 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GuideProperty 이용 개발가이드 GW URL 동적 주입
- sample/mock 응답 유형의 인증 절차 생략 로직 추가
- Webhook 이벤트 코드명/JSON 값 수정
2026-08-11 11:46:49 +09:00
rinjae 67ec6d2103 Jenkinsfile.deploy 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 11:12:57 +09:00
rinjae b024f8e86f Jenkinsfile.test-build 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 11:09:38 +09:00
rinjae fd16300553 Jenkinsfile.test-build 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 10:51:00 +09:00
Rinjae 815c329e3c CI - 단위테스트 안정화 및 e2e 분리 워크플로 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- AccountControllerTest 현 컨트롤러 API 기준 재작성
- Selenium 4종·SignupFileTest @Tag("e2e") 부여
- BaseWebTest headless 옵션, e2e 워크플로 신설

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 20:21:01 +09:00
Rinjae 7fdd70dbc7 ci - checkout step 이름·주석 정리
- 자신 모듈은 this branch, 의존은 master 명시
- step 이름과 주석으로 의도 가시화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 19:50:58 +09:00
Rinjae 3dd9edc849 eapim-portal CI 테스트 워크플로 추가 2026-06-12 19:40:20 +09:00
Rinjae 6cff96f40a CI - systemd 유닛 동기화 및 리로드 단계 제거 2026-06-12 18:45:55 +09:00
Rinjae a97609d533 Gitea Generic Package 업로드 워크플로 추가
- WAR 파일 Gitea Generic Package 업로드 단계 추가
- 패키지 버전 및 업로드 실패 시 오류 처리 로직 포함
2026-06-12 18:43:18 +09:00
113 changed files with 5112 additions and 628 deletions
+3
View File
@@ -83,6 +83,9 @@ file_upload/
# Ignore Gradle build output directory # Ignore Gradle build output directory
build build
# SonarScanner 작업 디렉터리
.scannerwork/
build_knbank.sh build_knbank.sh
+10 -3
View File
@@ -185,8 +185,9 @@ com.eactive.apim.portal/
설정: `config/PortalDatasourceConfiguration.java` 설정: `config/PortalDatasourceConfiguration.java`
- 각 데이터베이스별 별도 EntityManager - 각 데이터베이스별 별도 EntityManager
- Atomikos JTA를 통한 분산 트랜잭션 - **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
- 트랜잭션 로그: `/Log/eapim/portal/` - `transactionManager` (@Primary) → EMS
- `gatewayTransactionManager` → Gateway
### 설정 프로파일 ### 설정 프로파일
@@ -359,7 +360,13 @@ return queryFactory.selectFrom(user)
적절한 propagation과 함께 `@Transactional` 사용: 적절한 propagation과 함께 `@Transactional` 사용:
- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성) - 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성)
- 읽기 전용 작업: 최적화를 위해 `@Transactional(readOnly = true)` - 읽기 전용 작업: 최적화를 위해 `@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 는 조회 전용이다.
### 에러 처리 ### 에러 처리
+501
View File
@@ -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"
}
}
}
}
}
+267
View File
@@ -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())}"
}
}
}
}
}
}
+59 -2
View File
@@ -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 { dependencies {
annotationProcessor "org.projectlombok:lombok:1.18.28" annotationProcessor "org.projectlombok:lombok:1.18.28"
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0", "org.mapstruct:mapstruct-processor:1.5.5.Final" annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0", "org.mapstruct:mapstruct-processor:1.5.5.Final"
@@ -42,9 +60,25 @@ dependencies {
// implementation project(':kjb-safedb') // implementation project(':kjb-safedb')
implementation('org.springframework.boot:spring-boot-starter') 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-web')
implementation('org.springframework.boot:spring-boot-starter-validation') 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: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.0'
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1' implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
@@ -129,7 +163,16 @@ bootRun {
// jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005' // jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005'
sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조) 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 +224,21 @@ test {
enabled = true 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 { bootWar {
archiveFileName = "eapim-portal-boot.war" archiveFileName = "eapim-portal-boot.war"
mainClass = 'com.eactive.apim.portal.PortalApplication' mainClass = 'com.eactive.apim.portal.PortalApplication'
rootSpec.exclude(localOnlyResources)
classpath = excludeLocalOnlyLibs(classpath)
} }
war { war {
@@ -192,6 +247,8 @@ war {
from('src/main/resources/jeus-web-dd.xml') { into 'WEB-INF' } from('src/main/resources/jeus-web-dd.xml') { into 'WEB-INF' }
from('src/main/resources/weblogic.xml') { into 'WEB-INF' } from('src/main/resources/weblogic.xml') { into 'WEB-INF' }
rootSpec.exclude(localOnlyResources)
classpath = excludeLocalOnlyLibs(classpath)
} }
task printSourceSets { task printSourceSets {
+58
View File
@@ -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}")
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<?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>
-->
</suppressions>
+41
View File
@@ -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}")
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -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;">&nbsp;</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;">&nbsp;</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;">&nbsp;|&nbsp;</span>고객센터 : ????-?????<span style="color:#c2c2bc;">&nbsp;|&nbsp;</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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

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

+112
View File
@@ -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%
+278
View File
@@ -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"
}
}
+352
View File
@@ -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
+173
View File
@@ -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
+26
View File
@@ -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
+69
View File
@@ -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
@@ -15,6 +15,10 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.ui.ModelMap; import org.springframework.ui.ModelMap;
@@ -79,12 +83,17 @@ public class ApiController {
} }
@GetMapping @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); Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
model.addAttribute("search", search); model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services")); 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("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount")); model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1"); model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
@@ -94,6 +103,19 @@ public class ApiController {
return "apps/apis/mainApiList"; 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 = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
}
@GetMapping("/testbed/api") @GetMapping("/testbed/api")
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) { 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)); 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) { private String buildSpecJson(String id, HttpServletRequest request) {
if (DEFAULT_TOKEN_API_ID.equals(id)) { if (DEFAULT_TOKEN_API_ID.equals(id)) {
try { try {
// 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로라
// GW 호스트를 붙이면 실재하지 않는 주소가 된다(servers 없음 → 문서 origin 사용).
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH); Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8); return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
} catch (IOException e) { } catch (IOException e) {
log.error("Failed to read default token api spec file", e); log.error("Failed to read default token api spec file", e);
return null; return null;
@@ -61,6 +62,6 @@ public class TestbedSpecController {
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) { if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null; 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 displayRoleCode;
private String apiGroupName; private String apiGroupName;
private String apiGroupId;
} }
@@ -12,6 +12,9 @@ import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -22,6 +25,57 @@ public class APISender {
private static final Logger logger = LoggerFactory.getLogger(APISender.class); 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, 단위: 초) 단일 기준. // 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
private final DjbTestbedGatewayProperty gatewayProperty; private final DjbTestbedGatewayProperty gatewayProperty;
@@ -36,7 +90,7 @@ public class APISender {
connection.setReadTimeout(t); connection.setReadTimeout(t);
} }
public String requestPost(String uri, String requestBody) throws IOException { public ApiResponse requestPost(String uri, String requestBody) throws IOException {
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}", logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
@@ -45,11 +99,11 @@ public class APISender {
HttpURLConnection connection = getHttpURLConnection(uri, requestBody); HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
String response = getResponse(connection); ApiResponse response = getResponse(connection);
connection.disconnect(); connection.disconnect();
if (logger.isDebugEnabled()) { 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; return response;
} }
@@ -75,7 +129,7 @@ public class APISender {
return uriBuilder.toString(); 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)); URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection(); HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
@@ -95,16 +149,16 @@ public class APISender {
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params)); logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
} }
String response = getResponse(connection); ApiResponse response = getResponse(connection);
connection.disconnect(); connection.disconnect();
if (logger.isDebugEnabled()) { 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; 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)); URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection(); HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
@@ -132,30 +186,67 @@ public class APISender {
outputStream.flush(); outputStream.flush();
} }
String response = getResponse(connection); ApiResponse response = getResponse(connection);
connection.disconnect(); connection.disconnect();
if (logger.isDebugEnabled()) { 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; 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(); int responseCode = connection.getResponseCode();
String contentType = connection.getContentType();
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
try (InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream(); InputStreamReader isr = new InputStreamReader(stream); InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream();
BufferedReader reader = new BufferedReader(isr)) { if (stream != null) {
try (InputStreamReader isr = new InputStreamReader(stream, charsetOf(contentType));
BufferedReader reader = new BufferedReader(isr)) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
response.append(line); 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 { private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
@@ -11,9 +11,13 @@ import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.util.Arrays;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import javax.servlet.Filter; import javax.servlet.Filter;
import javax.servlet.FilterChain; 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 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 @Override
public void init(FilterConfig filterConfig) throws ServletException { public void init(FilterConfig filterConfig) throws ServletException {
logger.debug("ApiTesterFilter initialized"); logger.debug("ApiTesterFilter initialized");
@@ -141,14 +152,16 @@ public class ApiTesterFilter implements Filter {
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}", logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
auditId, target, body.length(), StringMaskingUtil.maskFormBody(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"); writeUpstream(response, tokenResponse);
response.getWriter().println(tokenResponse);
} }
} else { } 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 방지) // URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) { if (apiSpecInfoDto == null) {
@@ -190,11 +203,12 @@ public class ApiTesterFilter implements Filter {
String targetUri; String targetUri;
Map<String, String[]> paramMap; Map<String, String[]> paramMap;
if (gw) { if (gw) {
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용. // original-url 은 브라우저가 spec 서버주소(대외)로 만든 값이라 포탈 서버에서 도달하지 못할
// 수 있다. path/query 만 떼어 대내 base-url 에 재조립한 주소로 forward 한다.
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다. // 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
headers.remove("host"); headers.remove("host");
headers.remove("Host"); headers.remove("Host");
targetUri = url; targetUri = gatewayProperty.resolveGatewayCallUrl(url);
paramMap = new HashMap<>(); paramMap = new HashMap<>();
} else { } else {
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지) // mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
@@ -212,19 +226,18 @@ public class ApiTesterFilter implements Filter {
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers)); requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
} }
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr; APISender.ApiResponse upstream;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) { if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody); upstream = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
} else { } else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap); upstream = apiSender.requestGet(targetUri, headers, paramMap);
} }
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}", logger.debug("{} response - auditId={}, target={}, status={}, respLen={}, preview={}",
auditType, auditId, targetUri, auditType, auditId, targetUri, upstream.getStatus(),
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr)); upstream.getBody() == null ? 0 : upstream.getBody().length(), previewOf(upstream.getBody()));
} }
response.setContentType("application/json"); writeUpstream(response, upstream);
response.getWriter().println(responseStr);
} }
} catch (java.net.SocketTimeoutException e) { } catch (java.net.SocketTimeoutException e) {
@@ -305,6 +318,25 @@ public class ApiTesterFilter implements Filter {
return sb.toString(); 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)을 파라미터 맵으로 파싱. */ /** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
private Map<String, String[]> extractQueryParams(String originalUrl) { private Map<String, String[]> extractQueryParams(String originalUrl) {
Map<String, String[]> paramMap = new HashMap<>(); Map<String, String[]> paramMap = new HashMap<>();
@@ -320,7 +352,53 @@ public class ApiTesterFilter implements Filter {
return paramMap; 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 로 마스킹. */ /** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
private String maskHeaders(Map<String, String> headers) { private String maskHeaders(Map<String, String> headers) {
StringBuilder sb = new StringBuilder("{"); StringBuilder sb = new StringBuilder("{");
@@ -11,8 +11,14 @@ public interface ApiSpecMapper {
ApiSpecInfoDto mapToDto(ApiSpecInfo apiSpecInfo); ApiSpecInfoDto mapToDto(ApiSpecInfo apiSpecInfo);
/**
* 조회 결과가 없으면 {@code null} 을 반환한다.
*
* <p>빈 DTO 를 반환하면 호출측의 "스펙 없음" 분기가 동작하지 않고 모든 필드가 null 인 DTO 로
* 진행돼(예: {@code responseType == null} → sample 취급) 오동작한다.</p>
*/
default ApiSpecInfoDto map(Optional<ApiSpecInfo> optionalApiSpecInfo) { default ApiSpecInfoDto map(Optional<ApiSpecInfo> optionalApiSpecInfo) {
return optionalApiSpecInfo.map(this::mapToDto).orElse(new ApiSpecInfoDto()); return optionalApiSpecInfo.map(this::mapToDto).orElse(null);
} }
} }
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
filteredApis.forEach(api -> { filteredApis.forEach(api -> {
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId()); ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
if (service != null) { if (service != null) {
api.setMainIcon(service.getMainIcon()); // mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
api.setApiGroupId(service.getId());
api.setApiGroupName(service.getGroupName()); 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.dto.ApiServiceTabInfo;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService; import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor; 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.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ApiServiceController { 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; private final ApiServiceService apiServiceService;
@@ -52,4 +65,28 @@ public class ApiServiceController {
return "apps/apiservice/apiServiceDetail"; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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") @Service("apiServiceService")
@Transactional @Transactional("gatewayTransactionManager")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class ApiServiceService { public class ApiServiceService {
@@ -109,5 +120,14 @@ public class ApiServiceService {
.orElse(null); .orElse(null);
} }
/**
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
*/
public String getMainIcon(String id) {
return apiServiceRepository.findById(id)
.map(ApiGroup::getMainIcon)
.orElse(null);
}
} }
@@ -23,6 +23,8 @@ import com.eactive.apim.portal.file.service.FileTypeDetector;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
import javax.validation.Valid; import javax.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -102,8 +104,28 @@ public class MyAppController {
List<AppRequest> appRequests = appServiceFacade.getPendingApiKeyList(user.getPortalOrg()); List<AppRequest> appRequests = appServiceFacade.getPendingApiKeyList(user.getPortalOrg());
List<ClientDTO> apikeys = appServiceFacade.getApikeyList(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("apiKeys", apikeys);
model.addAttribute("openRequestByClient", openRequestByClient);
return new ModelAndView(API_KEY_LIST); return new ModelAndView(API_KEY_LIST);
} }
@@ -184,6 +206,10 @@ public class MyAppController {
model.addAttribute("apiKey", apiKey); model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable); model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id)); model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
// 해지 불가 사유(변경 신청 진행 중 등) — 클릭 시 사전검사가 이 사유를 안내하고 2FA 로 넘어가지 않는다
model.addAttribute("deleteBlockReason", appServiceFacade.resolveDeleteBlockReason(id));
// 걸려 있는 미완료 변경/해지 신청 — 상태 표시 + 취소 버튼용(결재 미개시 건도 포함)
model.addAttribute("openRequest", appServiceFacade.findOpenRequest(id));
return new ModelAndView(CREDENTIAL_DETAIL); return new ModelAndView(CREDENTIAL_DETAIL);
} }
@@ -247,6 +273,36 @@ public class MyAppController {
* @param requestData 요청 데이터 (clientId, reason) * @param requestData 요청 데이터 (clientId, reason)
* @return 성공/실패 결과를 담은 Map * @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") @PostMapping("/api_key_delete")
@Secured("ROLE_API_KEY_REQUEST") @Secured("ROLE_API_KEY_REQUEST")
@ResponseBody @ResponseBody
@@ -36,12 +36,12 @@ public class ApiKeyRegistrationDTO implements Serializable {
private byte[] appIconData; private byte[] appIconData;
private String appIconContentType; private String appIconContentType;
@NotBlank(message = " 이름을 입력해주세요.") @NotBlank(message = "클라이언트 이름을 입력해주세요.")
@Length(max = 100, message = " 이름은 100자를 초과할 수 없습니다.") @Length(max = 100, message = "클라이언트 이름은 100자를 초과할 수 없습니다.")
private String appName; private String appName;
@NotBlank(message = " 설명을 입력해주세요.") @NotBlank(message = "클라이언트 설명을 입력해주세요.")
@Length(max = 500, message = " 설명은 500자를 초과할 수 없습니다.") @Length(max = 500, message = "클라이언트 설명은 500자를 초과할 수 없습니다.")
private String appDescription; private String appDescription;
private String callbackUrl; private String callbackUrl;
@@ -37,7 +37,7 @@ public class AdminGatewayClient {
String baseUrl = portalPropertyService.getOrCreateProperty( String baseUrl = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL"); PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH; String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다. // 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId); ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
@@ -51,4 +51,18 @@ public class AdminGatewayClient {
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId); log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
} }
/**
* 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.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository; import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval; 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.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState; import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto; 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) { public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드 // 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn( String blockReason = resolveDeleteBlockReason(clientId);
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE)); if (blockReason != null) {
for (AppRequest r : related) { throw new IllegalStateException(blockReason);
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("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
} }
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다) // 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
@@ -203,6 +192,58 @@ public class AppServiceFacade {
beginApproval(saved.getApproval().getId()); 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)인지 확인합니다. * 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
* 상세 화면의 해지 버튼 비활성화에 사용됩니다. * 상세 화면의 해지 버튼 비활성화에 사용됩니다.
@@ -2,8 +2,6 @@ package com.eactive.apim.portal.apps.auth;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
@@ -12,10 +10,18 @@ import org.springframework.stereotype.Component;
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청 * <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml * 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로, * {@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> 를 반환한다(운영 환경 인증번호 노출 금지). * <p><b>운영(prod)에서 DB 값만으로 켤 수 있다.</b> 대신 종료일
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p> * {@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 @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -23,21 +29,45 @@ public class AuthNoticeProperties {
public static final String GROUP = "Portal"; 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_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 PortalPropertyService portalPropertyService;
private final Environment environment; private final TestNoticeWindow testNoticeWindow;
private volatile boolean cachedEnabled;
/** 캐시 갱신 시각(ms). 0 이면 미조회 */
private volatile long cachedAt;
/** /**
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용). * 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
* prod 환경에서는 property 값과 무관하게 항상 false. * 운영에서는 종료일({@link #KEY_TEST_NOTICE_PROD_UNTIL})까지만 참이 된다.
*/ */
public boolean isTestNoticeEnabled() { 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; return false;
} }
String value = portalPropertyService.getOrCreateProperty( String until = portalPropertyService.getOrCreateProperty(
GROUP, KEY_TEST_NOTICE_ENABLED, "true", GROUP, KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
"인증(이메일/SMS) 요청 시 인증번호 화면 표시할지 여부 (true/false, 테스트 전용)"); "운영(prod)에서 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
return value != null && "true".equalsIgnoreCase(value.trim()); 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;
}
}
}
@@ -41,8 +41,9 @@ public class TwoFactorController {
public TwoFactorSendResponse send(@RequestParam String channel, public TwoFactorSendResponse send(@RequestParam String channel,
@RequestParam(required = false) String purpose, @RequestParam(required = false) String purpose,
@RequestParam(required = false, defaultValue = "false") boolean force, @RequestParam(required = false, defaultValue = "false") boolean force,
HttpServletRequest request,
HttpSession session) { 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; 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.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.RequiredArgsConstructor; 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_TTL_SECONDS = "two-factor.ttl.seconds";
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit"; 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_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"; public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
private final PortalPropertyService portalPropertyService; private final PortalPropertyService portalPropertyService;
private final TestNoticeWindow testNoticeWindow;
/** 로그인 2FA 활성화 여부 */ /** 로그인 2FA 활성화 여부 */
public boolean isLoginEnabled() { public boolean isLoginEnabled() {
@@ -91,9 +94,21 @@ public class TwoFactorProperties {
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5); 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() { 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) { private String resolve(String key, String defaultValue, String description) {
@@ -115,7 +115,8 @@ public class TwoFactorService {
// SEND // 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(); TwoFactorSendResponse res = new TwoFactorSendResponse();
TwoFactorContext.Mode mode = resolveMode(session); TwoFactorContext.Mode mode = resolveMode(session);
@@ -156,7 +157,7 @@ public class TwoFactorService {
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?"); res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
return res; return res;
} }
discardContext(session, ctx); // 강제 종료(감사 기록 포함) discardContext(request, session, ctx); // 강제 종료(감사 기록 포함)
} else if (ctx.getStartedAt() != null } else if (ctx.getStartedAt() != null
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) { && ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
res.setValid(false); res.setValid(false);
@@ -280,7 +281,7 @@ public class TwoFactorService {
String stateError = revalidateLoginState(user); String stateError = revalidateLoginState(user);
if (stateError != null) { if (stateError != null) {
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
LoginFailureReason.ACCOUNT_DISABLED); LoginFailureReason.ACCOUNT_DISABLED, request.getHeader("User-Agent"));
clearPending(session); clearPending(session);
res.setValid(false); res.setValid(false);
res.setTerminated(true); res.setTerminated(true);
@@ -316,7 +317,8 @@ public class TwoFactorService {
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) { if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID); 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) { if (ctx != null && ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient()); 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) { if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID); 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) { if (ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient()); authNumberStorage.deleteAuthNumber(ctx.getRecipient());
@@ -472,7 +475,8 @@ public class TwoFactorService {
LoginFailureReason reason, HttpServletRequest request) { LoginFailureReason reason, HttpServletRequest request) {
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) { if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID); 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); clearPending(session);
} }
if (ctx.getRecipient() != null) { if (ctx.getRecipient() != null) {
@@ -6,6 +6,7 @@ import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository @Repository
@EMSDataSource @EMSDataSource
@@ -16,4 +17,8 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다. * createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/ */
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy); List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
@Transactional
long deleteByCreatedBy(String createdBy);
} }
@@ -44,6 +44,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
FileTypeContext.setFileType("business"); FileTypeContext.setFileType("business");
file = fileService.createFile(partnershipApplicationDTO.getFiles()); file = fileService.createFile(partnershipApplicationDTO.getFiles());
} finally { } finally {
FileTypeContext.clear();
FileService.clearInternalUserContext(); FileService.clearInternalUserContext();
} }
} }
@@ -7,10 +7,14 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository @Repository
@EMSDataSource @EMSDataSource
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> { public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id); Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
@Transactional
long deleteByInquirer_Id(String inquirerId);
} }
@@ -92,7 +92,12 @@ public class LoginFinalizer {
String ip = request.getRemoteAddr(); String ip = request.getRemoteAddr();
String sessionId = request.getSession().getId(); String sessionId = request.getSession().getId();
userLogService.logSuccess(rawUsername, ip, sessionId, loginType); // 다른 기기/브라우저의 활성 세션이 남아 있는 상태의 로그인인지(아래 forceLogoutOtherSessions 로
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
duplicate);
String contextPath = request.getContextPath(); String contextPath = request.getContextPath();
HttpSession session = request.getSession(); HttpSession session = request.getSession();
@@ -12,17 +12,22 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional @Transactional
public class PortalUserLogService { public class PortalUserLogService {
/** PTL_USER_LOG.USER_AGENT 컬럼 길이. 초과분은 잘라서 저장한다(초과 시 ORA-12899 로 로그인 실패). */
private static final int USER_AGENT_MAX_LENGTH = 500;
private final UserLogRepository userLogRepository; private final UserLogRepository userLogRepository;
public PortalUserLogService(UserLogRepository userLogRepository) { public PortalUserLogService(UserLogRepository userLogRepository) {
this.userLogRepository = userLogRepository; this.userLogRepository = userLogRepository;
} }
public void logSuccess(String userId, String ip, String sessionId) { /**
logSuccess(userId, ip, sessionId, LoginType.NORMAL); * 로그인 성공 기록.
} *
* @param duplicate 로그인 시점에 다른 활성 세션이 남아 있어 기존 세션이 강제 해제된 건이면 true
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType) { */
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType, String userAgent,
boolean duplicate) {
UserLog log = new UserLog(); UserLog log = new UserLog();
log.setLoginId(userId); log.setLoginId(userId);
log.setLoginTime(LocalDateTime.now()); log.setLoginTime(LocalDateTime.now());
@@ -30,15 +35,13 @@ public class PortalUserLogService {
log.setSessionId(sessionId); log.setSessionId(sessionId);
log.setSuccess(true); log.setSuccess(true);
log.setLoginType(loginType != null ? loginType.name() : null); log.setLoginType(loginType != null ? loginType.name() : null);
log.setUserAgent(truncateUserAgent(userAgent));
log.setDuplicate(duplicate);
userLogRepository.save(log); userLogRepository.save(log);
} }
public void logFailure(String userId, String ip, String sessionId) { public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason, String userAgent) {
logFailure(userId, ip, sessionId, LoginFailureReason.UNKNOWN);
}
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason) {
UserLog log = new UserLog(); UserLog log = new UserLog();
log.setLoginId(userId); log.setLoginId(userId);
log.setLoginTime(LocalDateTime.now()); log.setLoginTime(LocalDateTime.now());
@@ -46,19 +49,29 @@ public class PortalUserLogService {
log.setSessionId(sessionId); log.setSessionId(sessionId);
log.setSuccess(false); log.setSuccess(false);
log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name()); log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name());
log.setUserAgent(truncateUserAgent(userAgent));
userLogRepository.save(log); userLogRepository.save(log);
} }
@Transactional @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(); UserLog log = new UserLog();
log.setLoginId(userId); log.setLoginId(userId);
log.setLogoutTime(LocalDateTime.now()); log.setLogoutTime(LocalDateTime.now());
log.setIp(ip); log.setIp(ip);
log.setSessionId(sessionId); log.setSessionId(sessionId);
log.setSuccess(true); log.setSuccess(true);
log.setUserAgent(truncateUserAgent(userAgent));
userLogRepository.save(log); 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);
}
} }
@@ -14,6 +14,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.security.ClientGuardService; import com.eactive.apim.portal.common.security.ClientGuardService;
import com.eactive.apim.portal.djb.footer.RelatedSite; import com.eactive.apim.portal.djb.footer.RelatedSite;
import com.eactive.apim.portal.djb.footer.RelatedSiteService; import com.eactive.apim.portal.djb.footer.RelatedSiteService;
import com.eactive.apim.portal.djb.guide.GuideProperty;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@ControllerAdvice @ControllerAdvice
@@ -40,6 +41,9 @@ public class GlobalControllerAdvice {
@Autowired @Autowired
private RelatedSiteService relatedSiteService; private RelatedSiteService relatedSiteService;
@Autowired
private GuideProperty guideProperty;
@Autowired @Autowired
private Environment environment; private Environment environment;
@@ -107,6 +111,18 @@ public class GlobalControllerAdvice {
return clientGuardService.isDevtoolsGuardEnabled(); return clientGuardService.isDevtoolsGuardEnabled();
} }
/**
* 상단 헤더 좌측 노출용 활성 프로파일 배지. 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)에서 조회. * 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다. * 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
@@ -133,4 +149,19 @@ public class GlobalControllerAdvice {
public List<RelatedSite> relatedSites() { public List<RelatedSite> relatedSites() {
return relatedSiteService.getSites(); 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,258 +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 com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
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;
import java.util.Set;
import java.util.stream.Collectors;
/**
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
*
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
*
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
* 기본은 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, 19개 테이블)까지 포함
* 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 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
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", "ofctelno")),
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")),
new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE"))
);
/** 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",
"PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY"
);
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
static final String PROP_GROUP = "Portal";
static final String PROP_ALLOW_IPS = "migration.internal.allow-ips";
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
private final JdbcTemplate jdbcTemplate;
private final PortalPropertyService portalPropertyService;
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
PortalPropertyService portalPropertyService) {
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
this.portalPropertyService = portalPropertyService;
}
@PostMapping("/encrypt-legacy")
@Transactional("transactionManager")
public Map<String, Object> encryptLegacy(HttpServletRequest request,
@RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "false") boolean includeAudit) {
assertAllowedIp(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;
int totalSkipped = 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");
totalSkipped += (int) r.get("skipped");
}
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
response.put("damoMode", resolveDamoMode());
response.put("includeAudit", includeAudit);
response.put("totalChanged", totalChanged);
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
response.put("totalSkipped", totalSkipped);
response.put("results", results);
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
return response;
}
private String resolveDamoMode() {
if (converter.isBypassMode()) {
return "BYPASS";
}
return converter.isFakeMode() ? "FAKE" : "REAL";
}
/**
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 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("skipped", 0);
r.put("error", e.getMessage());
return r;
}
int changed = 0;
int skipped = 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.isEmpty()) && !value.isEmpty()) {
// 방어: 원본이 비어있지 않은데 정규화 결과가 빈 값 → 절대 UPDATE 하지 않음 (데이터 소실 방지)
skipped++;
log.warn("[마이그레이션] 정규화 결과가 빈 값 — UPDATE 생략 table={} column={} valueLen={}",
table, column, value.length());
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);
r.put("skipped", skipped);
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)로 기동한 뒤 실행하세요.");
}
}
/**
* PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사.
* 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델)
* 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에
* 호출자 IP 를 추가하고 완료 후 원복한다.
*/
private void assertAllowedIp(HttpServletRequest request) {
String remote = canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
.map(String::trim)
.filter(ip -> !ip.isEmpty())
.map(LegacyEncryptionMigrationController::canonicalize)
.collect(Collectors.toSet());
if (viaProxy || !allowed.contains(remote)) {
log.warn("[마이그레이션] 비허용 접근 차단 remoteAddr={} viaProxy={} xff={}",
StringMaskingUtil.maskIpAddress(remote), viaProxy,
StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"허용되지 않은 접근입니다. (PTL_PROPERTY " + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
}
}
private String resolveAllowIps() {
try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
DEFAULT_ALLOW_IPS, "레거시 암호화 마이그레이션 내부 API 허용 IP 목록(콤마 구분)");
} catch (Exception e) {
log.warn("[마이그레이션] 허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
return DEFAULT_ALLOW_IPS;
}
}
/** IPv6 loopback 표기 통일 */
private static String canonicalize(String ip) {
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
}
private static final class TargetTable {
final String table;
final List<String> columns;
TargetTable(String table, List<String> columns) {
this.table = table;
this.columns = columns;
}
}
}
@@ -3,9 +3,18 @@ package com.eactive.apim.portal.common.util;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/** /**
* 사용자 유형 판별 유틸리티 * 사용자 유형 판별 유틸리티
* 내부 운영자와 외부 사용자를 구분하는 공통 로직 제공 * 내부 운영자와 외부 사용자를 구분하는 공통 로직 제공
*
* 내부 사용자로 인정할 이메일 도메인은 application.yml 의
* {@code portal.internal-user.email-domains} 로 관리한다.
* 설정이 없으면 {@link #DEFAULT_INTERNAL_EMAIL_DOMAINS} 를 사용한다.
*/ */
public final class UserTypeUtil { public final class UserTypeUtil {
@@ -13,7 +22,47 @@ public final class UserTypeUtil {
throw new IllegalStateException("Utility class"); throw new IllegalStateException("Utility class");
} }
private static final String INTERNAL_EMAIL_DOMAIN = "@kjbank.com"; /** 설정 미주입 시 사용할 기본 내부 도메인 */
private static final List<String> DEFAULT_INTERNAL_EMAIL_DOMAINS =
Collections.unmodifiableList(Arrays.asList("@djbank.com", "@unit-test.com"));
private static volatile List<String> internalEmailDomains = DEFAULT_INTERNAL_EMAIL_DOMAINS;
/**
* 내부 사용자 이메일 도메인 목록 설정.
* {@code UserTypeConfigurer} 가 기동 시 application.yml 값으로 주입한다.
*
* @param domains 도메인 목록 ("@" 접두사 유무 무관, 대소문자 무관)
*/
public static void setInternalEmailDomains(List<String> domains) {
if (domains == null || domains.isEmpty()) {
internalEmailDomains = DEFAULT_INTERNAL_EMAIL_DOMAINS;
return;
}
List<String> normalized = new ArrayList<>();
for (String domain : domains) {
if (StringUtils.isBlank(domain)) {
continue;
}
String value = domain.trim().toLowerCase();
if (!value.startsWith("@")) {
value = "@" + value;
}
if (!normalized.contains(value)) {
normalized.add(value);
}
}
internalEmailDomains = normalized.isEmpty()
? DEFAULT_INTERNAL_EMAIL_DOMAINS
: Collections.unmodifiableList(normalized);
}
/**
* 현재 적용 중인 내부 사용자 이메일 도메인 목록
*/
public static List<String> getInternalEmailDomains() {
return internalEmailDomains;
}
/** /**
* 현재 사용자가 내부 운영자인지 확인 * 현재 사용자가 내부 운영자인지 확인
@@ -21,10 +70,28 @@ public final class UserTypeUtil {
* @return 내부 운영자 여부 * @return 내부 운영자 여부
*/ */
public static boolean isInternalUser(PortalAuthenticatedUser user) { public static boolean isInternalUser(PortalAuthenticatedUser user) {
if (user == null || StringUtils.isEmpty(user.getEmailAddr())) { if (user == null) {
return false; return false;
} }
return user.getEmailAddr().toLowerCase().endsWith(INTERNAL_EMAIL_DOMAIN); return isInternalEmail(user.getEmailAddr());
}
/**
* 이메일 주소의 호스트(도메인)로 내부 운영자 여부 판별
* @param emailAddr 이메일 주소
* @return 내부 운영자 여부
*/
public static boolean isInternalEmail(String emailAddr) {
if (StringUtils.isEmpty(emailAddr)) {
return false;
}
String email = emailAddr.trim().toLowerCase();
for (String domain : internalEmailDomains) {
if (email.endsWith(domain)) {
return true;
}
}
return false;
} }
/** /**
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import javax.naming.NamingException; import javax.naming.NamingException;
import javax.sql.DataSource; import javax.sql.DataSource;
@@ -58,11 +59,11 @@ public class BaseDatasourceConfiguration {
//public interface AvailableSettings extends org.hibernate.jpa.AvailableSettings 참고 //public interface AvailableSettings extends org.hibernate.jpa.AvailableSettings 참고
properties.put("hibernate.dialect", prop.getHibernateDialect()); properties.put("hibernate.dialect", prop.getHibernateDialect());
properties.put("hibernate.connection.handling_mode", "DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION"); properties.put("hibernate.connection.handling_mode", "DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION");
properties.put("hibernate.transaction.jta.platform", "org.hibernate.engine.transaction.jta.platform.internal.AtomikosJtaPlatform");
properties.put("hibernate.physical_naming_strategy", prop.getHibernatePhysicalNamingStrategy()); properties.put("hibernate.physical_naming_strategy", prop.getHibernatePhysicalNamingStrategy());
properties.put("hibernate.default_schema", prop.getSchema()); properties.put("hibernate.default_schema", prop.getSchema());
properties.put("javax.persistence.validation.mode", "none"); properties.put("javax.persistence.validation.mode", "none");
properties.put("javax.persistence.transactionType", "JTA"); // JTA/XA 미사용. EMF 별 로컬 트랜잭션(JpaTransactionManager)으로 처리한다 - PortalConfigTransaction 참고
properties.put("javax.persistence.transactionType", "RESOURCE_LOCAL");
properties.put("hibernate.format_sql", "true"); properties.put("hibernate.format_sql", "true");
if (prop instanceof EmsDatasourceProperty) { if (prop instanceof EmsDatasourceProperty) {
@@ -110,9 +111,22 @@ public class BaseDatasourceConfiguration {
return builder return builder
.dataSource(dataSource) .dataSource(dataSource)
// entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지) // entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지)
.packages(prop.getEntityPackage().split("\\s*,\\s*")) .packages(splitPackages(prop.getEntityPackage()))
.persistenceUnit(persistenceUnit) .persistenceUnit(persistenceUnit)
.properties(properties) .properties(properties)
.build(); .build();
} }
/**
* 콤마로 나열된 엔티티 패키지를 분리한다. 앞뒤 공백은 걷어내고 빈 토큰은 버린다.
*
* <p>구분자 정규식 {@code "\\s*,\\s*"} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
* 단순 분리 + trim 으로 대체했다.</p>
*/
private static String[] splitPackages(String entityPackage) {
return Arrays.stream(entityPackage.split(","))
.map(String::trim)
.filter(pkg -> !pkg.isEmpty())
.toArray(String[]::new);
}
} }
@@ -18,7 +18,8 @@ import javax.sql.DataSource;
@Configuration @Configuration
@EntityScan(basePackages = {"com.eactive.apim.gateway"}) @EntityScan(basePackages = {"com.eactive.apim.gateway"})
@EnableJpaRepositories(basePackages = "com.eactive.apim.gateway", repositoryBaseClass = BaseRepositoryImpl.class, entityManagerFactoryRef = "gatewayEntityManagerFactory") @EnableJpaRepositories(basePackages = "com.eactive.apim.gateway", repositoryBaseClass = BaseRepositoryImpl.class,
entityManagerFactoryRef = "gatewayEntityManagerFactory", transactionManagerRef = "gatewayTransactionManager")
@Slf4j @Slf4j
public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration { public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration {
private final Environment env; private final Environment env;
@@ -0,0 +1,165 @@
package com.eactive.apim.portal.config;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.info.Info;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.AbstractHandlerMapping;
import org.springframework.web.servlet.handler.MappedInterceptor;
/**
* HandlerInterceptor 목록을 Actuator 로 노출한다.
*
* <p>배경: Actuator 의 {@code /actuator/mappings} 는 servlet filter / servlet / handler mapping 만
* 보여주고 interceptor 는 항목 자체가 없다. {@code /actuator/beans} 로도 안 보이는데,
* {@link PortalConfigWebDispatcherServlet#addInterceptors} 가 {@code new} 로 직접 생성해 등록하므로
* 스프링 빈이 아니기 때문이다. 그래서 HandlerMapping 내부 목록을 직접 읽어서 노출한다.
*
* <p>노출 경로 2가지:
* <ul>
* <li>{@code GET /actuator/interceptors} — 전용 엔드포인트</li>
* <li>{@code GET /actuator/info} 의 {@code interceptors} 항목 — Spring Boot Admin 은 커스텀
* 엔드포인트용 화면이 없으므로, SBA UI(Details 탭의 Info 카드)에서 보려면 이쪽이 필요하다.
* {@code management.info.env.enabled} 와 무관하게 InfoContributor 는 항상 동작한다.</li>
* </ul>
*
* <p>이 설정은 actuator 가 classpath 에 있을 때만 활성화된다. actuator 는 build.gradle 에서
* 로컬 전용(runtimeOnly + war 산출물 제외)이므로 WebLogic 배포본(dev/stage/prod)에서는
* 조건이 거짓이 되어 이 클래스 자체가 로드되지 않는다.
*/
@Configuration
@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint")
public class InterceptorsEndpointConfig {
@Bean
public InterceptorsEndpoint interceptorsEndpoint(ApplicationContext applicationContext) {
return new InterceptorsEndpoint(applicationContext);
}
@Endpoint(id = "interceptors")
public static class InterceptorsEndpoint implements InfoContributor {
/** AbstractHandlerMapping 이 실제로 실행하는 interceptor 목록(private final). */
private static final Field ADAPTED_INTERCEPTORS =
ReflectionUtils.findField(AbstractHandlerMapping.class, "adaptedInterceptors");
/** MappedInterceptor 의 제외 패턴(private final PatternAdapter[]). include 는 public getter 가 있다. */
private static final Field EXCLUDE_PATTERNS =
ReflectionUtils.findField(MappedInterceptor.class, "excludePatterns");
static {
if (ADAPTED_INTERCEPTORS != null) {
ReflectionUtils.makeAccessible(ADAPTED_INTERCEPTORS);
}
if (EXCLUDE_PATTERNS != null) {
ReflectionUtils.makeAccessible(EXCLUDE_PATTERNS);
}
}
private final ApplicationContext applicationContext;
public InterceptorsEndpoint(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
/**
* HandlerMapping 빈 이름 → 등록 순서대로의 interceptor 목록.
* 목록 순서가 곧 preHandle 실행 순서다(afterCompletion 은 역순).
*/
@ReadOperation
public Map<String, List<Map<String, Object>>> interceptors() {
Map<String, List<Map<String, Object>>> result = new TreeMap<>();
Map<String, AbstractHandlerMapping> mappings =
applicationContext.getBeansOfType(AbstractHandlerMapping.class);
for (Map.Entry<String, AbstractHandlerMapping> entry : mappings.entrySet()) {
List<Map<String, Object>> described = describe(entry.getValue());
if (!described.isEmpty()) {
result.put(entry.getKey(), described);
}
}
return result;
}
@Override
public void contribute(Info.Builder builder) {
builder.withDetail("interceptors", interceptors());
}
private List<Map<String, Object>> describe(AbstractHandlerMapping mapping) {
if (ADAPTED_INTERCEPTORS == null) {
return Collections.emptyList();
}
Object raw = ReflectionUtils.getField(ADAPTED_INTERCEPTORS, mapping);
if (!(raw instanceof List)) {
return Collections.emptyList();
}
List<Map<String, Object>> described = new ArrayList<>();
int order = 0;
for (Object each : (List<?>) raw) {
described.add(describe(order++, (HandlerInterceptor) each));
}
return described;
}
private Map<String, Object> describe(int order, HandlerInterceptor interceptor) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("order", order);
if (interceptor instanceof MappedInterceptor) {
MappedInterceptor mapped = (MappedInterceptor) interceptor;
row.put("interceptor", mapped.getInterceptor().getClass().getName());
row.put("include", nullSafe(mapped.getPathPatterns()));
row.put("exclude", excludePatterns(mapped));
} else {
// WebMvcConfigurationSupport 가 자동으로 넣는 것들(ConversionServiceExposing 등).
// 경로 제한이 없어 모든 요청에 적용된다.
row.put("interceptor", interceptor.getClass().getName());
row.put("include", Collections.singletonList("/**"));
row.put("exclude", Collections.emptyList());
}
return row;
}
/** PatternAdapter[] → 패턴 문자열 목록. PatternAdapter 는 package-private 이라 리플렉션으로 읽는다. */
private List<String> excludePatterns(MappedInterceptor mapped) {
if (EXCLUDE_PATTERNS == null) {
return Collections.emptyList();
}
Object adapters = ReflectionUtils.getField(EXCLUDE_PATTERNS, mapped);
if (adapters == null) {
return Collections.emptyList();
}
int length = Array.getLength(adapters);
List<String> patterns = new ArrayList<>(length);
for (int i = 0; i < length; i++) {
Object adapter = Array.get(adapters, i);
Method getPatternString = ReflectionUtils.findMethod(adapter.getClass(), "getPatternString");
if (getPatternString == null) {
continue;
}
ReflectionUtils.makeAccessible(getPatternString);
patterns.add((String) ReflectionUtils.invokeMethod(getPatternString, adapter));
}
return patterns;
}
private List<String> nullSafe(String[] values) {
return values == null ? Collections.emptyList() : Arrays.asList(values);
}
}
}
@@ -0,0 +1,31 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* 내부 API 공유 토큰 초기화 — 포탈 기동 시 1회.
*
* <p>PTL_PROPERTY {@code Portal / internal.api.header-name}, {@code internal.api.token} 이 없으면
* 이 시점에 생성된다(토큰은 SecureRandom 난수). eapim-admin 은 이 값을 <b>읽기만</b> 하므로,
* 토큰을 처음 만드는 주체는 포탈 한 곳으로 고정된다.</p>
*
* <p>{@link ApplicationReadyEvent} 를 쓰는 이유: 데이터소스/JTA 초기화가 끝난 뒤여야 프로퍼티를 저장할 수 있다.
* WAR 배포(WebLogic)에서도 동일하게 발생한다.</p>
*
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
*/
@Component
@RequiredArgsConstructor
public class InternalApiTokenInitializer {
private final InternalApiTokenService internalApiTokenService;
@EventListener(ApplicationReadyEvent.class)
public void initialize() {
internalApiTokenService.initialize();
}
}
@@ -97,7 +97,8 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
} }
} }
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception)); userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception),
request.getHeader("User-Agent"));
// 로그인 실패 시 세션 정보 로깅 // 로그인 실패 시 세션 정보 로깅
logLoginFailure(request, username, exception); logLoginFailure(request, username, exception);
@@ -0,0 +1,89 @@
package com.eactive.apim.portal.config;
import javax.servlet.DispatcherType;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.filter.ForwardedHeaderFilter;
/**
* 리버스프록시(OHS) 전달 헤더 처리 필터 등록.
*
* <p>{@code server.forward-headers-strategy=framework} 일 때만 활성화된다. Spring Boot 오토컨피그
* ({@code ServletWebServerFactoryAutoConfiguration.ForwardedHeaderFilterConfiguration}) 가 동일 조건에서
* 기본 {@link ForwardedHeaderFilter} 를 등록하지만, 여기서 직접 등록해 <b>{@code /internal/**} 를 필터 대상에서
* 제외</b>한다. 오토컨피그는 해당 필터 빈이 이미 있으면 물러난다.
*
* <h3>왜 native 가 아니라 framework 인가</h3>
* {@code native} 는 내장 Tomcat 의 {@code RemoteIpValve} 에 위임하는 전략이라 WAR 로 WebLogic 에 배포하면
* 아무 동작도 하지 않는다({@link com.eactive.apim.portal.PortalApplication} 은 {@code SpringBootServletInitializer}
* 상속). {@code framework} 는 평범한 서블릿 필터라 배포 형태와 무관하게 동작한다.
*
* <h3>왜 {@code /internal/**} 를 제외하는가</h3>
* {@link ForwardedHeaderFilter} 는 (1) {@code getRemoteAddr()} 을 {@code X-Forwarded-For} 값으로 치환하고
* (2) 처리한 forwarded 헤더를 요청에서 제거한다. 내부 API 는 이 두 값으로 접근을 통제하므로 그대로 두면 가드가 뒤집힌다.
*
* <pre>
* 공격자: X-Forwarded-For: 127.0.0.1
* → getRemoteAddr() = "127.0.0.1" (위조값이 허용 IP 검사를 통과)
* → getHeader("X-Forwarded-For") = null (필터가 제거 → viaProxy 판정이 false)
* </pre>
*
* 즉 {@code MenuInternalController#isAllowedIp} 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다.
* 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로 검사되므로 기존 가드가 설계대로 동작한다.
*
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
*
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
*/
@Slf4j
@Configuration
@ConditionalOnProperty(name = "server.forward-headers-strategy", havingValue = "framework")
public class PortalConfigForwardedHeader {
/** 프록시 헤더를 신뢰하지 않고 원 소켓 IP 로 판정해야 하는 경로. */
private static final String INTERNAL_PREFIX = "/internal/";
@Bean
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilterRegistration() {
FilterRegistrationBean<ForwardedHeaderFilter> registration =
new FilterRegistrationBean<>(new InternalExcludingForwardedHeaderFilter());
// 다른 필터(XSS, Security 등)가 요청을 읽기 전에 scheme/host/port/remoteAddr 이 교정되어야 한다.
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR);
log.info("ForwardedHeaderFilter 등록 - X-Forwarded-* 반영, 제외 경로: {}**", INTERNAL_PREFIX);
return registration;
}
/** {@code /internal/**} 요청은 원본 요청 그대로 통과시키는 {@link ForwardedHeaderFilter}. */
static class InternalExcludingForwardedHeaderFilter extends ForwardedHeaderFilter {
/**
* {@code super} 구현은 "forwarded 계열 헤더가 하나도 없으면 필터링하지 않는다"이다.
* 그 최적화를 유지해야 프록시를 거치지 않는 환경(로컬·내부 직접 접속)의 동작이 기존과 동일하게 남으므로
* 내부 경로 판정과 반드시 OR 로 결합한다.
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return isInternal(request) || super.shouldNotFilter(request);
}
private boolean isInternal(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null) {
return false;
}
// context-path 는 현재 "/" 이지만, 변경되더라도 경로 판정이 어긋나지 않게 접두를 걷어낸다.
String contextPath = request.getContextPath();
if (contextPath != null && contextPath.length() > 1 && uri.startsWith(contextPath)) {
uri = uri.substring(contextPath.length());
}
return uri.startsWith(INTERNAL_PREFIX);
}
}
}
@@ -107,9 +107,16 @@ public class PortalConfigSecurity {
.logoutSuccessHandler(logoutSuccessHandler)) .logoutSuccessHandler(logoutSuccessHandler))
.csrf(csrf -> csrf .csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository) .csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*")) // /_proxy 는 대응 핸들러가 없어 예외를 해제했다. 경로가 부활하면 아래를 되살릴 것.
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**")) // .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**")) // /internal/migration 은 LegacyEncryptionMigrationController 제거와 함께 삭제됨.
// /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다.
// 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다.
// (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다)
// /internal/test-cleanup 도 동일 이유(Playwright 등 세션 없는 호출) + 동일 통제 방식.
.ignoringRequestMatchers(
new AntPathRequestMatcher("/internal/menu/**"),
new AntPathRequestMatcher("/internal/test-cleanup/**"))
) )
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면 // 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져 // 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
@@ -1,39 +1,44 @@
package com.eactive.apim.portal.config; package com.eactive.apim.portal.config;
import com.atomikos.icatch.jta.UserTransactionImp; import javax.persistence.EntityManagerFactory;
import com.atomikos.icatch.jta.UserTransactionManager;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Primary;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.jta.JtaTransactionManager;
import javax.transaction.SystemException;
import javax.transaction.UserTransaction;
/**
* 트랜잭션 매니저 설정.
*
* <p>EMS(포털)·AGW(게이트웨이) 두 데이터소스를 쓰지만 <b>JTA/XA 는 사용하지 않는다</b>.
* 두 DB 를 한 트랜잭션으로 묶어 쓰는 지점이 없고(게이트웨이 측은 조회 전용),
* 이전 Atomikos 구성도 {@code AtomikosDataSourceBean} 없이 JNDI/Hikari 데이터소스를 그대로 넘겨
* XA 리소스가 하나도 enlist 되지 않는 형태였다. 즉 2PC 는 실제로 동작한 적이 없고
* {@code com.atomikos.icatch.max_actives} 상한(기본 50)만 병목으로 남았다.</p>
*
* <p>따라서 EntityManagerFactory 별로 로컬 {@link JpaTransactionManager} 를 둔다.
* 게이트웨이 리포지토리는 {@code GatewayDatasourceConfiguration} 의
* {@code transactionManagerRef = "gatewayTransactionManager"} 로 연결된다.</p>
*/
@Configuration @Configuration
@EnableTransactionManagement @EnableTransactionManagement
public class PortalConfigTransaction { public class PortalConfigTransaction {
@Bean(name = "userTransaction") /** EMS(포털) 기본 트랜잭션 매니저. {@code @Transactional} 무지정 시 이 매니저가 쓰인다. */
public UserTransaction userTransaction() throws SystemException { @Primary
UserTransactionImp userTransactionImp = new UserTransactionImp();
userTransactionImp.setTransactionTimeout(30000);
return userTransactionImp;
}
@Bean(name = "atomikosTransactionManager", initMethod = "init", destroyMethod = "close")
public UserTransactionManager atomikosTransactionManager() {
UserTransactionManager userTransactionManager = new UserTransactionManager();
userTransactionManager.setForceShutdown(false);
return userTransactionManager;
}
@Bean(name = "transactionManager") @Bean(name = "transactionManager")
@DependsOn({"userTransaction", "atomikosTransactionManager"}) public PlatformTransactionManager transactionManager(
public PlatformTransactionManager transactionManager(UserTransactionManager atomikosTransactionManager) throws SystemException { @Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) {
UserTransaction userTransaction = userTransaction(); return new JpaTransactionManager(entityManagerFactory);
return new JtaTransactionManager(userTransaction, atomikosTransactionManager); }
/** AGW(게이트웨이) 트랜잭션 매니저. {@code @Transactional("gatewayTransactionManager")} 로 지정해 쓴다. */
@Bean(name = "gatewayTransactionManager")
public PlatformTransactionManager gatewayTransactionManager(
@Qualifier("gatewayEntityManagerFactory") EntityManagerFactory gatewayEntityManagerFactory) {
return new JpaTransactionManager(gatewayEntityManagerFactory);
} }
} }
@@ -5,6 +5,7 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Data @Data
@@ -25,6 +26,20 @@ public class PortalProperties {
private FileProperties file = new FileProperties(); private FileProperties file = new FileProperties();
private InternalUserProperties internalUser = new InternalUserProperties();
/**
* 내부 사용자(운영자) 판별 설정
*/
@Data
public static class InternalUserProperties {
/**
* 내부 사용자로 분류할 이메일 도메인 목록.
* "@" 접두사는 있어도 되고 없어도 되며, 대소문자를 구분하지 않는다.
*/
private List<String> emailDomains = new ArrayList<>();
}
/** /**
* 파일 업로드 관련 설정 * 파일 업로드 관련 설정
*/ */
@@ -0,0 +1,26 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
/**
* application.yml 의 {@code portal.internal-user.email-domains} 값을
* 정적 유틸리티인 {@link UserTypeUtil} 에 주입한다.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class UserTypeConfigurer {
private final PortalProperties portalProperties;
@PostConstruct
public void init() {
UserTypeUtil.setInternalEmailDomains(portalProperties.getInternalUser().getEmailDomains());
log.info("내부 사용자 이메일 도메인: {}", UserTypeUtil.getInternalEmailDomains());
}
}
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.qna.entity.InquiryComment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
@@ -17,4 +18,7 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
+ " group by c.inquiry.id") + " group by c.inquiry.id")
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds, List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
@Param("delYn") String delYn); @Param("delYn") String delYn);
@Transactional
long deleteByInquiry_Inquirer_Id(String inquirerId);
} }
@@ -0,0 +1,45 @@
package com.eactive.apim.portal.djb.guide;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 개발가이드 안내페이지에 노출할 값을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.guide.})
* 에서 읽는 얇은 접근자.
*
* <p>안내페이지에 표기되는 게이트웨이 주소는 서비스 환경(dev/stage/prod)마다 달라지므로 템플릿에 하드코딩하지 않고
* DB 프로퍼티로 분리한다. 값 미존재 시 {@link PortalPropertyService#getOrCreateProperty} 가 기본값으로 INSERT 하며,
* 공백 값은 {@link #resolve} 에서 기본값으로 폴백한다({@code getOrCreateProperty} 는 공백 보정을 하지 않음).
*/
@Component
@RequiredArgsConstructor
public class GuideProperty {
private final PortalPropertyService portalPropertyService;
private static final String GROUP = "Portal";
public static final String KEY_GW_URL = "djb.guide.gw-url";
public static final String DEFAULT_GW_URL = "https://openapi.jejubank.co.kr";
/** 안내페이지(OAuth2 개발가이드 등)에 표기할 게이트웨이 base URL. 끝 슬래시는 제거한다. */
public String gatewayUrl() {
return stripTrailingSlash(resolve(KEY_GW_URL, DEFAULT_GW_URL,
"개발가이드 안내페이지에 표기할 게이트웨이 base URL (예: https://openapi.jejubank.co.kr)"));
}
private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue;
}
private static String stripTrailingSlash(String s) {
if (s == null) {
return "";
}
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.djb.menu; package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
import com.eactive.apim.portal.common.util.IpAddressMatcher; import com.eactive.apim.portal.common.util.IpAddressMatcher;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -19,14 +20,20 @@ import java.util.Map;
/** /**
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용. * 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
* *
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록 * <p>가드는 두 겹이다.</p>
* (기본 loopback) + X-Forwarded-For 동반 요청 거부 * <ol>
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델). * <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}. 헤더명/토큰은 PTL_PROPERTY
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에 * {@code Portal / internal.api.header-name}, {@code internal.api.token} 으로 관리하며
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다. * 토큰은 포탈 최초 기동 시 자동 생성된다. 이 경로는 CSRF 예외 대상
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p> * (PortalConfigSecurity {@code /internal/menu/**})이라, 커스텀 헤더 요구가 CSRF 를 대신한다 —
* 브라우저의 cross-site form POST 는 커스텀 헤더를 붙일 수 없다.</li>
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / menu.internal.allow-ips}(기본 loopback)
* + X-Forwarded-For 동반 요청 거부. 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.</li>
* </ol>
* *
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre> * <pre>curl -X POST -H 'X-Internal-Token: &lt;PTL_PROPERTY Portal/internal.api.token&gt;' \
* http://127.0.0.1:39130/internal/menu/reload</pre>
*/ */
@Slf4j @Slf4j
@RestController @RestController
@@ -43,14 +50,18 @@ public class MenuInternalController {
private final MenuService menuService; private final MenuService menuService;
private final PortalPropertyService portalPropertyService; private final PortalPropertyService portalPropertyService;
private final InternalApiTokenService internalApiTokenService;
@PostMapping("/reload") @PostMapping("/reload")
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) { public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
if (!isAllowed(request)) { if (!isAllowedIp(request)) {
Map<String, Object> denied = new LinkedHashMap<>(); return denied(HttpStatus.FORBIDDEN,
denied.put("result", "DENIED"); "허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)"); }
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied); if (!hasValidToken(request)) {
return denied(HttpStatus.UNAUTHORIZED,
"내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/"
+ InternalApiTokenService.PROP_TOKEN + " 확인)");
} }
MenuService.MenuSnapshot snapshot = menuService.reload(); MenuService.MenuSnapshot snapshot = menuService.reload();
@@ -63,12 +74,31 @@ public class MenuInternalController {
return ResponseEntity.ok(body); return ResponseEntity.ok(body);
} }
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
Map<String, Object> denied = new LinkedHashMap<>();
denied.put("result", "DENIED");
denied.put("message", message);
return ResponseEntity.status(status).body(denied);
}
/**
* 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다).
*/
private boolean hasValidToken(HttpServletRequest request) {
String headerName = internalApiTokenService.ensureHeaderName();
if (!internalApiTokenService.matches(request.getHeader(headerName))) {
log.warn("메뉴 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName);
return false;
}
return true;
}
/** /**
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. * 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나 * (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.) * 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
*/ */
private boolean isAllowed(HttpServletRequest request) { private boolean isAllowedIp(HttpServletRequest request) {
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr()); String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null; boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
@@ -2,7 +2,10 @@ package com.eactive.apim.portal.djb.testbed.config;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode; import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.net.URI;
import java.net.URISyntaxException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -19,6 +22,7 @@ import org.springframework.util.StringUtils;
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class DjbTestbedGatewayProperty { public class DjbTestbedGatewayProperty {
private final PortalPropertyService portalPropertyService; private final PortalPropertyService portalPropertyService;
@@ -33,8 +37,17 @@ public class DjbTestbedGatewayProperty {
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy"; public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy"; public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token"; public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
public static final String KEY_SPEC_SERVER_URL = "djb.gateway.spec-server-url";
public static final String DEFAULT_BASE_URL = "PortalMock"; public static final String DEFAULT_BASE_URL = "PortalMock";
/**
* {@link #KEY_SPEC_SERVER_URL} 미설정 표식. spec 서버 주소를 {@link #resolveApiBaseUrl} 기존 규칙
* (GATEWAY→base-url, PortalMock→포탈 origin)으로 결정한다는 뜻.
*
* <p>빈 문자열을 기본값으로 쓰면 {@code PTL_PROPERTY.property_value} 가 NOT NULL 이라
* Oracle 에서 빈 문자열=NULL 로 저장되며 제약 위반이 나므로, 비어 있지 않은 표식을 쓴다.</p>
*/
public static final String DEFAULT_SPEC_SERVER_URL = "auto";
public static final String DEFAULT_TIMEOUT_SEC = "10"; public static final String DEFAULT_TIMEOUT_SEC = "10";
public static final String DEFAULT_USE_PROXY = "true"; public static final String DEFAULT_USE_PROXY = "true";
public static final String DEFAULT_TOKEN_USE_PROXY = "true"; public static final String DEFAULT_TOKEN_USE_PROXY = "true";
@@ -130,15 +143,42 @@ public class DjbTestbedGatewayProperty {
} }
/** /**
* OAuth 토큰 발급 URL. * 브라우저에 내려주는 OAuth 토큰 발급 URL — spec 노출 주소({@link #resolveSpecServerUrl}) 기준.
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔), *
* GATEWAY → {@code baseUrl + tokenPath}. * <p>PortalMock → {@code portalOrigin + /api/v1/oauth/token}(기존 mock 필터가 가로챔),
* GATEWAY → {@code specServerUrl + tokenPath}.</p>
*
* <p>대외/대내 주소가 다른 환경에서 <b>직접호출 모드</b>({@code djb.gateway.token-use-proxy=false})는
* 브라우저가 이 주소로 바로 나가므로 대외 주소여야 한다. 프록시 경유일 때는 이 값이
* {@code original-url} 헤더로만 전달되고, 실제 forward 는 필터가
* {@code baseUrl + tokenPath}(대내)로 수행한다.</p>
*/ */
public String resolveTokenUrl(String portalOrigin) { public String resolveTokenUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) { if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH; return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
} }
return stripTrailingSlash(baseUrl()) + tokenPath(); return stripTrailingSlash(resolveSpecServerUrl(portalOrigin)) + tokenPath();
}
/**
* gw 응답유형의 실제 forward 대상 URL. 브라우저가 spec 서버주소(대외)로 만든 {@code originalUrl} 에서
* path/query 만 떼어 <b>대내 {@code base-url}</b> 에 재조립한다 — 포탈 서버는 대외 주소로 나갈 수 없다.
*
* <p>PortalMock 모드는 실 GW 가 없으므로 원본을 그대로 쓴다. URL 파싱 실패 시에도 원본 유지(멱등).</p>
*/
public String resolveGatewayCallUrl(String originalUrl) {
if (originalUrl == null || resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return originalUrl;
}
try {
URI uri = new URI(originalUrl);
String path = StringUtils.hasText(uri.getRawPath()) ? uri.getRawPath() : "";
String query = StringUtils.hasText(uri.getRawQuery()) ? "?" + uri.getRawQuery() : "";
return stripTrailingSlash(baseUrl()) + path + query;
} catch (URISyntaxException e) {
log.warn("gw 호출 URL 파싱 실패, original-url 그대로 사용 - url={}, cause={}", originalUrl, e.getMessage());
return originalUrl;
}
} }
/** /**
@@ -153,6 +193,30 @@ public class DjbTestbedGatewayProperty {
return stripTrailingSlash(baseUrl()); return stripTrailingSlash(baseUrl());
} }
/**
* spec({@code servers[0].url})에 노출할 주소. 응답유형과 무관하게 이 값 하나로 고정된다.
*
* <p>{@link #KEY_SPEC_SERVER_URL} 에 주소를 넣으면 그 값을 그대로 쓴다. PortalMock 모드처럼
* {@code base-url} 이 실제 URL 이 아니어서 노출할 GW 주소를 코드가 알 수 없을 때 이 값으로 고정한다.
* 대외 공개 도메인이 내부 GW 주소와 다른 경우에도 사용한다.</p>
*
* <p>{@value #DEFAULT_SPEC_SERVER_URL}(기본) 이면 {@link #resolveApiBaseUrl} 규칙을 그대로 따른다.</p>
*/
public String resolveSpecServerUrl(String portalOrigin) {
String configured = specServerUrl();
if (StringUtils.hasText(configured) && !DEFAULT_SPEC_SERVER_URL.equalsIgnoreCase(configured.trim())) {
return stripTrailingSlash(configured);
}
return resolveApiBaseUrl(portalOrigin);
}
/** spec servers 노출 주소. {@value #DEFAULT_SPEC_SERVER_URL} 이면 미설정(자동 결정). */
public String specServerUrl() {
return resolve(KEY_SPEC_SERVER_URL, DEFAULT_SPEC_SERVER_URL,
"spec(servers)에 노출할 API 호출 주소. \"" + DEFAULT_SPEC_SERVER_URL
+ "\" 이면 GW Base URL(PortalMock 이면 포탈 주소)로 자동 결정");
}
private String resolve(String key, String defaultValue, String description) { private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description); String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue; return StringUtils.hasText(value) ? value.trim() : defaultValue;
@@ -48,34 +48,37 @@ public class DjbTestbedSpecController {
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException { public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, true); String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
} }
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml") @GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException { public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, true); String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json)); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
} }
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */ /**
* Swagger UI 전용 spec. 서버 주소는 다운로드용과 동일하게 GW 기준이며, 여기에만
* {@code x-original-api-id} 를 심어 UI 실행 시 프록시가 API ID 로 스펙을 찾게 한다.
*/
@GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException { public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, false); String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
} }
/** /**
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. * default 토큰 spec 또는 저장 spec(auth enrich + 서버 GW 치환)을 JSON 으로 반환. 없으면 null.
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용) * @param forUi true 면 Swagger UI 실행용으로 {@code x-original-api-id} 를 주입한다
*/ */
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException { private String buildSpecJson(String id, HttpServletRequest request, boolean forUi) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) { if (DEFAULT_TOKEN_API_ID.equals(id)) {
// 클래스패스 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로
// (PORTAL_MOCK_TOKEN_PATH)라 GW 호스트를 붙이면 실재하지 않는 주소가 되고, servers 를
// 비워 두면 Swagger UI 가 문서 origin(포탈)을 사용해 프록시가 mock 토큰을 발급한다.
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH); Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8); return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return alwaysGateway
? serverRewriter.rewriteServerToGateway(content, request)
: serverRewriter.rewriteServer(content, null, request);
} }
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id); Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
@@ -85,8 +88,12 @@ public class DjbTestbedSpecController {
DjbAuthType authType = authService.resolveAuthType(id); DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType); String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return alwaysGateway String rewritten = serverRewriter.rewriteServerToGateway(enriched, request);
? serverRewriter.rewriteServerToGateway(enriched, request) if (!forUi) {
: serverRewriter.rewriteServer(enriched, spec.get(), request); return rewritten;
}
// UI 용 spec 에만 API ID 를 심는다. 서버주소가 GW 로 고정돼 mock 은 호출 URL 의 path 로 스펙을
// 되찾을 수 없으므로, Swagger UI 가 original-api-id 헤더로 프록시에 API ID 를 전달하게 한다.
return enricher.injectOriginalApiId(rewritten, id);
} }
} }
@@ -32,5 +32,11 @@ public class DjbTestbedContextDto {
private String tokenUrl; private String tokenUrl;
private String apiKeyHeader; private String apiKeyHeader;
private String oauthTokenHeader; private String oauthTokenHeader;
/**
* sample/mock 응답유형 API 에서 실제 토큰 발급 없이 주입할 고정 액세스 토큰.
* (실호출이 없거나 mock 서버로만 나가므로 검증 대상이 아니다 — {@code ApiTesterFilter} 의 mock 토큰과 동일 값)
*/
private String mockAccessToken;
} }
} }
@@ -35,6 +35,13 @@ public class DjbSwaggerSpecEnricher {
public static final String OAUTH_SCHEME = "djbOAuth"; public static final String OAUTH_SCHEME = "djbOAuth";
public static final String API_KEY_SCHEME = "djbApiKey"; public static final String API_KEY_SCHEME = "djbApiKey";
/** operation 에 주입하는 API ID 확장 키. 템플릿 JS 가 {@code original-api-id} 헤더로 프록시에 전달한다. */
public static final String API_ID_EXTENSION = "x-original-api-id";
/** OpenAPI path item 에서 operation 으로 취급하는 필드 (그 외 parameters/servers/summary 등은 제외). */
private static final String[] HTTP_METHODS =
{"get", "put", "post", "delete", "options", "head", "patch", "trace"};
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException { public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
JsonNode parsed = objectMapper.readTree(specJson); JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) { if (!parsed.isObject()) {
@@ -71,6 +78,42 @@ public class DjbSwaggerSpecEnricher {
return objectMapper.writeValueAsString(root); return objectMapper.writeValueAsString(root);
} }
/**
* spec 의 모든 operation 에 {@value #API_ID_EXTENSION} 를 주입한다.
*
* <p>테스트베드 프록시({@code /api/call-api})는 스펙을 찾을 때 {@code original-url} 의 path 로 매칭했는데,
* mock/gw 응답유형에서는 서버 주소가 mockUrl·GW base 로 치환되어 path 가 저장된 {@code api_url} 과
* 일치하지 않는다. 템플릿 JS 가 이 확장값을 {@code original-api-id} 헤더로 보내면 프록시가 API ID 로
* 정확히 스펙을 찾을 수 있다.</p>
*
* <p>파싱 불가·paths 부재 시 원본 유지(멱등).</p>
*/
public String injectOriginalApiId(String specJson, String apiId) throws JsonProcessingException {
if (specJson == null || apiId == null || apiId.trim().isEmpty()) {
return specJson;
}
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
return specJson;
}
JsonNode paths = parsed.get("paths");
if (paths == null || !paths.isObject()) {
return specJson;
}
for (JsonNode pathItem : paths) {
if (!pathItem.isObject()) {
continue;
}
for (String method : HTTP_METHODS) {
JsonNode operation = pathItem.get(method);
if (operation != null && operation.isObject()) {
((ObjectNode) operation).put(API_ID_EXTENSION, apiId);
}
}
}
return objectMapper.writeValueAsString(parsed);
}
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */ /** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
private ObjectNode buildApiKeyScheme(DjbAuthType authType) { private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
String headerName = (authType == DjbAuthType.OAUTH) String headerName = (authType == DjbAuthType.OAUTH)
@@ -119,6 +119,7 @@ public class DjbTestbedAuthService {
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin)) .tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
.apiKeyHeader(gatewayProperty.apiKeyHeader()) .apiKeyHeader(gatewayProperty.apiKeyHeader())
.oauthTokenHeader(gatewayProperty.oauthHeader()) .oauthTokenHeader(gatewayProperty.oauthHeader())
.mockAccessToken(gatewayProperty.mockAccessToken())
.build(); .build();
} }
@@ -1,8 +1,10 @@
package com.eactive.apim.portal.djb.testbed.service; package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty; import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -12,58 +14,61 @@ import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
/** /**
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을 * testbed spec(JSON)의 서버 주소를 제공 시점에 게이트웨이 주소로 치환한다.
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
* *
* <ul> * <p><b>spec 의 {@code servers} 는 응답유형(sample/mock/gw)과 무관하게 항상 GW 기준이다.</b>
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li> * spec 은 "이 API 를 실제로 어디로 호출하는가"의 공개 계약이고 그 기준은 게이트웨이 하나다.
* <li>mock : {@code ApiSpecInfo.mockUrl}</li> * admin(app.js buildOpenApiSpec)도 같은 규칙으로 저장하며, 포탈은 제공 시점에 자기 환경의
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li> * {@link DjbTestbedGatewayProperty#resolveSpecServerUrl} 로 덮어써 저장 환경(dev)의 주소가 다른 환경까지
* </ul> * 따라오지 않게 한다. 노출 주소를 명시 고정해야 하면
* {@code djb.gateway.spec-server-url}(PTL_PROPERTY) 에 넣는다.
* *
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시). * <p>응답유형에 따른 차이는 <b>테스트베드 동작</b>에서만 난다 — Swagger UI 의 실행은
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며, * {@code /api/call-api} 프록시를 타고, {@code ApiTesterFilter} 가 응답유형별로
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다. * sample(저장 샘플 응답) / mock({@code mockUrl} forward) / gw(게이트웨이 forward) 를 가른다.
* 즉 표시 주소는 GW 로 고정이고 호출 대상만 갈린다.
* *
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등). * <p>치환은 서버 값 유무와 무관하게 트리 조작으로 덮어쓴다(문자열 치환 아님). 과거 admin 이 mock/포탈
* 주소를 굳혀 저장한 spec 도 GW 기준으로 정정된다. 다만 그 시절 mock 저장본은 {@code paths} 키가 mock
* 경로로 바뀌어 있어, 어댑터경로 복원은 admin 재저장이 필요하다.
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class DjbTestbedSpecServerRewriter { public class DjbTestbedSpecServerRewriter {
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
public static final String SERVER_SENTINEL = "http://swagger-server-url";
private final DjbTestbedGatewayProperty gatewayProperty; private final DjbTestbedGatewayProperty gatewayProperty;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(resolveBase(spec, request));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/** /**
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환. * spec 의 {@code servers} 를 GW 주소로 치환해 반환한다. UI 표시용·다운로드용 구분 없이 동일 기준.
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml) *
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.) * @return 치환된 JSON. 파싱 실패·형태 불일치 시 원본 유지(멱등).
*/ */
public String rewriteServerToGateway(String specJson, HttpServletRequest request) { public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) { if (specJson == null) {
return null;
}
String base = stripTrailingSlash(gatewayProperty.resolveSpecServerUrl(originOf(request)));
try {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
if (StringUtils.hasText(base)) {
ArrayNode servers = objectMapper.createArrayNode();
servers.add(objectMapper.createObjectNode().put("url", base));
root.set("servers", servers);
} else {
// GW 주소 미확정 — servers 를 비워 Swagger UI 가 문서 origin(포탈)을 쓰게 한다.
root.remove("servers");
}
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
log.error("spec 서버 치환 실패, 원본 유지 - base={}", base, e);
return specJson; return specJson;
} }
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
} }
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */ /** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
@@ -80,20 +85,6 @@ public class DjbTestbedSpecServerRewriter {
} }
} }
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
? "sample" : spec.getResponseType().trim();
if ("gw".equalsIgnoreCase(rt)) {
return gatewayProperty.resolveApiBaseUrl(originOf(request));
}
if ("mock".equalsIgnoreCase(rt)) {
String mock = (spec == null) ? null : spec.getMockUrl();
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
}
// sample(기본): 포탈 origin
return originOf(request);
}
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */ /** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
public static String originOf(HttpServletRequest request) { public static String originOf(HttpServletRequest request) {
if (request == null) { if (request == null) {
@@ -0,0 +1,190 @@
package com.eactive.apim.portal.djb.testcleanup.controller;
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
import com.eactive.apim.portal.common.util.IpAddressMatcher;
import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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;
/**
* Playwright E2E 테스트 정리용 내부 API — local(개인 dev 프로필, 항상 {@code dev} 동반 활성화)과
* 배포된 dev 서버 양쪽에서만 존재한다({@code @Profile("dev")}, stage/prod 는 빈 자체가 없어 404).
*
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
* <ol>
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}(menu.reload 와 동일 토큰 재사용).</li>
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / testcleanup.internal.allow-ips}(기본 loopback).
* menu 와 별도 프로퍼티라 Playwright 실행 호스트만 좁게 허용할 수 있다.</li>
* </ol>
*
* <p>파괴적 작업 안전장치: {@code org}/{@code user} 삭제는 구체적 식별자(compRegNo/email)가 필수이며
* 와일드카드/전체삭제 파라미터는 없다. {@code orphans} 스윕은 이미 사라진 org/user 를 참조하는 행만
* 대상이라 살아있는 테스트 데이터를 지울 위험이 없다.</p>
*
* <pre>curl -X POST -H 'X-Internal-Token: &lt;PTL_PROPERTY Portal/internal.api.token&gt;' \
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
*/
@Slf4j
@Profile("dev")
@RestController
@RequestMapping("/internal/test-cleanup")
@RequiredArgsConstructor
public class TestCleanupInternalController {
static final String PROP_GROUP = "Portal";
static final String PROP_ALLOW_IPS = "testcleanup.internal.allow-ips";
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
static final String PROP_ALLOW_IPS_DESCRIPTION =
"테스트 정리 내부 API(법인/계정/고아데이터 삭제) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, "
+ "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원";
private final TestCleanupService testCleanupService;
private final OrphanCleanupService orphanCleanupService;
private final PortalPropertyService portalPropertyService;
private final InternalApiTokenService internalApiTokenService;
@PostMapping("/org")
public ResponseEntity<Map<String, Object>> deleteOrg(
@RequestParam String compRegNo, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(compRegNo)) {
return badRequest("compRegNo 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteOrgCascade(compRegNo);
log.info("테스트 정리(org) 실행 - compRegNo: {}, from: {}", compRegNo, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("compRegNo", compRegNo);
body.put("orgId", result.getTargetId());
body.put("orgFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
@PostMapping("/user")
public ResponseEntity<Map<String, Object>> deleteUser(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteUserCascade(email);
log.info("테스트 정리(user) 실행 - email: {}, from: {}", email, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("userFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
@PostMapping("/orphans")
public ResponseEntity<Map<String, Object>> cleanOrphans(HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
TestCleanupResult result = orphanCleanupService.sweep();
log.info("테스트 정리(orphans) 실행 - from: {}", request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
private ResponseEntity<Map<String, Object>> checkGuards(HttpServletRequest request) {
if (!isAllowedIp(request)) {
return denied(HttpStatus.FORBIDDEN,
"허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
}
if (!hasValidToken(request)) {
return denied(HttpStatus.UNAUTHORIZED,
"내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/"
+ InternalApiTokenService.PROP_TOKEN + " 확인)");
}
return null;
}
private Map<String, Object> baseBody() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("result", "OK");
body.put("processedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return body;
}
private ResponseEntity<Map<String, Object>> badRequest(String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("result", "BAD_REQUEST");
body.put("message", message);
return ResponseEntity.badRequest().body(body);
}
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
Map<String, Object> denied = new LinkedHashMap<>();
denied.put("result", "DENIED");
denied.put("message", message);
return ResponseEntity.status(status).body(denied);
}
/**
* 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다).
*/
private boolean hasValidToken(HttpServletRequest request) {
String headerName = internalApiTokenService.ensureHeaderName();
if (!internalApiTokenService.matches(request.getHeader(headerName))) {
log.warn("테스트 정리 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName);
return false;
}
return true;
}
/**
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
*/
private boolean isAllowedIp(HttpServletRequest request) {
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), remote)) {
log.warn("테스트 정리 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
return false;
}
return true;
}
private String resolveAllowIps() {
try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
DEFAULT_ALLOW_IPS, PROP_ALLOW_IPS_DESCRIPTION);
} catch (Exception e) {
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
return DEFAULT_ALLOW_IPS;
}
}
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
* 13개 테이블에서 스윕 삭제한다. local(dev 동반 활성화)·dev 서버에서만 동작한다({@link TestCleanupService}
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
*/
@Slf4j
@Profile("dev")
@Service
@Transactional
public class OrphanCleanupService {
private final Environment environment;
@PersistenceContext
private EntityManager entityManager;
public OrphanCleanupService(Environment environment) {
this.environment = environment;
}
public TestCleanupResult sweep() {
assertNonProdProfile();
TestCleanupResult result = TestCleanupResult.found(null);
// 자식 -> 부모 순서 (FK 제약은 없으나 논리적 정합성 유지 목적)
result.put("PTL_INQUIRY_COMMENT", exec(
"DELETE FROM PTL_INQUIRY_COMMENT WHERE INQUIRY_ID IN "
+ "(SELECT ID FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER))"));
result.put("PTL_INQUIRY", exec(
"DELETE FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER)"));
result.put("PTL_PARTNERSHIP_APPLICATION", exec(
"DELETE FROM PTL_PARTNERSHIP_APPLICATION WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)"));
result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", exec(
"DELETE FROM PTL_USER_PRIVACY_POLICY_AGREEMENT WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)"));
result.put("PTL_USER_PASSWORD_HISTORY", exec(
"DELETE FROM PTL_USER_PASSWORD_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT ID FROM PTL_USER)"));
result.put("PTL_USER_ROLE_HISTORY", exec(
"DELETE FROM PTL_USER_ROLE_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)"));
result.put("PTL_USER_LOG", exec(
"DELETE FROM PTL_USER_LOG WHERE LOGIN_ID IS NOT NULL AND LOGIN_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)"));
result.put("PTL_CREDENTIAL_API", exec(
"DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN "
+ "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG))"));
result.put("PTL_CREDENTIAL", exec(
"DELETE FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG)"));
result.put("PTL_WEBHOOK_REQ_API", exec(
"DELETE FROM PTL_WEBHOOK_REQ_API WHERE WEBHOOK_REQ_ID IN "
+ "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))"));
result.put("PTL_WEBHOOK_REQ_EVENT", exec(
"DELETE FROM PTL_WEBHOOK_REQ_EVENT WHERE WEBHOOK_REQ_ID IN "
+ "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))"));
result.put("PTL_WEBHOOK_REQ", exec(
"DELETE FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)"));
result.put("PTL_WEBHOOK_SEND_LOG", exec(
"DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)"));
log.info("테스트 정리 - 고아 데이터 스윕 완료: {}", result.getDeletedCounts());
return result;
}
private long exec(String sql) {
return entityManager.createNativeQuery(sql).executeUpdate();
}
private void assertNonProdProfile() {
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
}
}
}
@@ -0,0 +1,33 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;
/**
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
*/
@Profile("dev")
@Repository
class TestCleanupNativeQueries {
@PersistenceContext
private EntityManager entityManager;
int deleteCredentialApiByOrgId(String orgId) {
return entityManager.createNativeQuery(
"DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN "
+ "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID = :orgId)")
.setParameter("orgId", orgId)
.executeUpdate();
}
int deleteWebhookSendLogByOrgId(String orgId) {
return entityManager.createNativeQuery(
"DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID = :orgId")
.setParameter("orgId", orgId)
.executeUpdate();
}
}
@@ -0,0 +1,45 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 테스트 정리 API 응답용 결과 누적기. 대상 존재 여부 + 테이블별 삭제 건수를 담는다.
*/
public class TestCleanupResult {
private boolean found;
private String targetId;
private final Map<String, Long> deletedCounts = new LinkedHashMap<>();
public static TestCleanupResult notFound() {
return new TestCleanupResult();
}
public static TestCleanupResult found(String targetId) {
TestCleanupResult result = new TestCleanupResult();
result.found = true;
result.targetId = targetId;
return result;
}
public void put(String table, long count) {
deletedCounts.merge(table, count, Long::sum);
}
public void merge(TestCleanupResult other) {
other.deletedCounts.forEach(this::put);
}
public boolean isFound() {
return found;
}
public String getTargetId() {
return targetId;
}
public Map<String, Long> getDeletedCounts() {
return deletedCounts;
}
}
@@ -0,0 +1,141 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과
* 그에 딸린 데이터를 하드 삭제한다. local(dev 동반 활성화)·dev 서버에서만 존재하는 빈이며,
* stage/prod 는 {@code @Profile("dev")} 로 빈 자체가 등록되지 않는다.
*
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
*/
@Slf4j
@Profile("dev")
@Service
@RequiredArgsConstructor
@Transactional
public class TestCleanupService {
private final Environment environment;
private final PortalOrgRepository portalOrgRepository;
private final PortalUserRepository portalUserRepository;
private final InquiryRepository inquiryRepository;
private final InquiryCommentRepository inquiryCommentRepository;
private final PartnershipApplicationRepository partnershipApplicationRepository;
private final UserRoleHistoryRepository userRoleHistoryRepository;
private final PortalUserPrivacyAgreementRepository portalUserPrivacyAgreementRepository;
private final UserPasswordHistoryRepository userPasswordHistoryRepository;
private final UserLogRepository userLogRepository;
private final CredentialRepository credentialRepository;
private final WebhookRequestRepository webhookRequestRepository;
private final WebhookRequestApiRepository webhookRequestApiRepository;
private final WebhookRequestEventRepository webhookRequestEventRepository;
private final WebhookService webhookService;
private final TestCleanupNativeQueries nativeQueries;
/**
* 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다.
*/
public TestCleanupResult deleteOrgCascade(String compRegNo) {
assertNonProdProfile();
Optional<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo);
if (!orgOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalOrg org = orgOpt.get();
String orgId = org.getId();
TestCleanupResult result = TestCleanupResult.found(orgId);
List<PortalUser> users = portalUserRepository.findAllByPortalOrg_Id(orgId);
for (PortalUser user : users) {
result.merge(deleteUserCascadeInternal(user));
}
result.put("PTL_CREDENTIAL_API", nativeQueries.deleteCredentialApiByOrgId(orgId));
result.put("PTL_CREDENTIAL", credentialRepository.deleteByOrgid(orgId));
Optional<WebhookRequest> webhook = webhookRequestRepository.findByOrgId(orgId);
if (webhook.isPresent()) {
Long webhookReqId = webhook.get().getId();
result.put("PTL_WEBHOOK_REQ_API", webhookRequestApiRepository.findByWebhookReqId(webhookReqId).size());
result.put("PTL_WEBHOOK_REQ_EVENT", webhookRequestEventRepository.findByWebhookReqId(webhookReqId).size());
webhookService.delete(webhookReqId, orgId);
result.put("PTL_WEBHOOK_REQ", 1L);
} else {
result.put("PTL_WEBHOOK_REQ_API", 0L);
result.put("PTL_WEBHOOK_REQ_EVENT", 0L);
result.put("PTL_WEBHOOK_REQ", 0L);
}
result.put("PTL_WEBHOOK_SEND_LOG", nativeQueries.deleteWebhookSendLogByOrgId(orgId));
portalOrgRepository.delete(org);
result.put("PTL_ORG", 1L);
log.info("테스트 정리 - 법인 완전삭제 완료: compRegNo={}, orgId={}, userCount={}", compRegNo, orgId, users.size());
return result;
}
/**
* 이메일로 계정을 찾아 단독 하드 삭제한다(org 소속 여부와 무관 — org 소유 CREDENTIAL/WEBHOOK 은 건드리지 않는다).
*/
public TestCleanupResult deleteUserCascade(String email) {
assertNonProdProfile();
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
TestCleanupResult result = deleteUserCascadeInternal(user);
log.info("테스트 정리 - 계정 완전삭제 완료: userId={}", user.getId());
return result;
}
private TestCleanupResult deleteUserCascadeInternal(PortalUser user) {
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId()));
result.put("PTL_INQUIRY", inquiryRepository.deleteByInquirer_Id(user.getId()));
result.put("PTL_PARTNERSHIP_APPLICATION", partnershipApplicationRepository.deleteByCreatedBy(user.getId()));
// UserRoleHistory.userId 는 loginId 를 저장한다(다른 이력 테이블과 시맨틱이 반대이므로 혼동 주의).
result.put("PTL_USER_ROLE_HISTORY", userRoleHistoryRepository.deleteByUserId(user.getLoginId()));
result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", portalUserPrivacyAgreementRepository.deleteByCreatedBy(user.getId()));
// UserPasswordHistory.userId 는 PortalUser.id 를 저장한다.
result.put("PTL_USER_PASSWORD_HISTORY", userPasswordHistoryRepository.deleteByUserId(user.getId()));
result.put("PTL_USER_LOG", userLogRepository.deleteByLoginId(user.getLoginId()));
portalUserRepository.delete(user);
result.put("PTL_USER", 1L);
return result;
}
private void assertNonProdProfile() {
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
}
}
}
+7 -3
View File
@@ -1,6 +1,4 @@
spring: spring:
jta:
enabled: false
config: config:
activate: activate:
on-profile: dev on-profile: dev
@@ -42,4 +40,10 @@ portal:
# auth-virtual-code: 654321 # auth-virtual-code: 654321
dev: dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영 # application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true hot-reload-pages: true
server:
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
+7 -3
View File
@@ -1,6 +1,4 @@
spring: spring:
jta:
enabled: false
config: config:
activate: activate:
on-profile: prod on-profile: prod
@@ -32,4 +30,10 @@ app:
resource-versioning: resource-versioning:
enabled: true enabled: true
resource-caching: resource-caching:
enabled: true enabled: true
server:
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
+6 -2
View File
@@ -1,6 +1,4 @@
spring: spring:
jta:
enabled: false
web: web:
resources: resources:
cache: cache:
@@ -47,3 +45,9 @@ gateway:
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
server:
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
+29 -8
View File
@@ -19,8 +19,22 @@ server:
whitelabel: whitelabel:
enabled: false enabled: false
path: /error path: /error
# 리버스프록시(OHS) 전달 헤더(X-Forwarded-*)를 요청에 반영하는 전략.
# native = 내장 Tomcat RemoteIpValve 위임 → WAR(WebLogic) 배포에서는 동작하지 않는다.
# framework = Spring ForwardedHeaderFilter(서블릿 필터) → 배포 방식과 무관하게 동작한다.
# 기본값 native: 로컬 개발은 gradle bootRun(내장 Tomcat)으로 기동하므로 컨테이너 기본 처리에 맡긴다.
# RemoteIpValve 는 internalProxies(사설/loopback 대역) 에서 온 요청의 헤더만 신뢰하므로 로컬에서 안전하다.
# 프록시 뒤에 놓이는 WAR 배포 환경(dev/stage/prod)만 각 프로파일에서 framework 로 올린다.
# ※ framework 로 올리는 환경은 OHS 에서 반드시 아래를 수행해야 한다(신뢰 프록시 목록이 없어 헤더를 무조건 신뢰).
# RequestHeader unset X-Forwarded-For / -Proto / -Host / -Port / Forwarded (클라이언트 위조분 제거)
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
forward-headers-strategy: native forward-headers-strategy: native
compression:
enabled: true
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
min-response-size: 1024
spring: spring:
config: config:
@@ -49,13 +63,7 @@ spring:
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가 # 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다. # 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
jta: # JTA/Atomikos 제거됨. EMS·AGW 각각 로컬 트랜잭션(JpaTransactionManager) 사용 - PortalConfigTransaction 참고
enabled: false
atomikos:
properties:
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
log-base-dir: /dev/null
log-base-name: adp_tx
thymeleaf: thymeleaf:
prefix: 'classpath:/templates/views/' prefix: 'classpath:/templates/views/'
@@ -82,6 +90,11 @@ security:
basic: basic:
enabled: false enabled: false
# ※ Actuator / Spring Boot Admin 설정은 이 파일에 두지 않는다.
# application-local*.yml 에만 두고, 해당 파일과 관련 라이브러리는 war/bootWar 산출물에서
# 제외된다(build.gradle 의 localOnly configuration + rootSpec.exclude 참고).
# WebLogic 배포본(dev/stage/prod)에는 actuator 자체가 존재하지 않는다.
sample-code-path: classpath:/templates/sample_code sample-code-path: classpath:/templates/sample_code
@@ -111,7 +124,15 @@ portal:
allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png
logging: logging:
log-path: /logs/eapim log-path: /logs/prod/eapim
# 내부 사용자(운영자) 판별 설정
# 로그인 계정 이메일의 호스트가 아래 도메인이면 내부 사용자로 분류한다.
# "@" 접두사 유무·대소문자 무관
internal-user:
email-domains:
- djbank.com
- unit-test.com
auth-ttl: 300 auth-ttl: 300
auth: auth:
-3
View File
@@ -1,3 +0,0 @@
# application.yml ? ??
# com.atomikos.icatch.log_base_dir=/Log/eapim/portal/
# com.atomikos.icatch.log_base_name=adp_tx
-3
View File
@@ -87,9 +87,6 @@
<logger name="org.springframework.orm.jpa" level="DEBUG" additivity="false"> <logger name="org.springframework.orm.jpa" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" /> <appender-ref ref="FILE_HIBERNATE" />
</logger> </logger>
<logger name="com.atomikos" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="eapim.portal.session" level="INFO" additivity="false"> <logger name="eapim.portal.session" level="INFO" additivity="false">
<appender-ref ref="HTTP_SESSION" /> <appender-ref ref="HTTP_SESSION" />
+19 -4
View File
@@ -19,6 +19,21 @@
<property name="__consoleLevel.FALSE" value="OFF"/> <property name="__consoleLevel.FALSE" value="OFF"/>
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/> <property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
<!-- 파일 로그(ROLLING/ERROR_FILE 등 root 하위 appender) 레벨 JVM 파라미터 제어 -->
<!-- 사용: -DFILE_LOG_LEVEL=ERROR (전체 미남김: -DFILE_LOG_LEVEL=OFF) -->
<!-- 기본값: dev 프로파일 DEBUG, 그 외 INFO (기존 동작 유지) -->
<property name="FILE_LOG_LEVEL" value="${FILE_LOG_LEVEL:-INFO}"/>
<property name="FILE_LOG_LEVEL_DEV" value="${FILE_LOG_LEVEL:-DEBUG}"/>
<!-- API 테스터 감사 로그(eapim.portal.apitester.audit) on/off JVM 파라미터 제어 -->
<!-- 사용: -DAPI_TESTER_AUDIT_LOG_ENABLED=false / 기본값: true(켜짐, INFO) -->
<property name="API_TESTER_AUDIT_LOG_ENABLED" value="${API_TESTER_AUDIT_LOG_ENABLED:-true}"/>
<property name="__apiTesterAuditLevel.true" value="INFO"/>
<property name="__apiTesterAuditLevel.TRUE" value="INFO"/>
<property name="__apiTesterAuditLevel.false" value="OFF"/>
<property name="__apiTesterAuditLevel.FALSE" value="OFF"/>
<property name="API_TESTER_AUDIT_EFFECTIVE_LEVEL" value="${__apiTesterAuditLevel.${API_TESTER_AUDIT_LOG_ENABLED}}"/>
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file> <file>${LOG_PATH}/hibernate.log</file>
@@ -95,23 +110,23 @@
</appender> </appender>
<logger name="eapim.portal.session" level="INFO" additivity="false"> <logger name="eapim.portal.session" level="${FILE_LOG_LEVEL}" additivity="false">
<appender-ref ref="HTTP_SESSION" /> <appender-ref ref="HTTP_SESSION" />
</logger> </logger>
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false"> <logger name="eapim.portal.apitester.audit" level="${API_TESTER_AUDIT_EFFECTIVE_LEVEL}" additivity="false">
<appender-ref ref="API_TESTER_AUDIT" /> <appender-ref ref="API_TESTER_AUDIT" />
</logger> </logger>
<root level="INFO"> <root level="${FILE_LOG_LEVEL}">
<appender-ref ref="ROLLING"/> <appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/> <appender-ref ref="ERROR_FILE"/>
</root> </root>
<springProfile name="dev"> <springProfile name="dev">
<root level="DEBUG"> <root level="${FILE_LOG_LEVEL_DEV}">
<appender-ref ref="ROLLING"/> <appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/> <appender-ref ref="ERROR_FILE"/>
+99 -47
View File
@@ -689,13 +689,17 @@ hr {
font-display: swap; font-display: swap;
src: local("Spoqa Han Sans Bold"), url("/font/spoqa-hans/SpoqaHanSansBold.woff2") format("woff2"), url("/font/spoqa-hans/SpoqaHanSansBold.woff") format("woff"), url("/font/spoqa-hans/SpoqaHanSansBold.ttf") format("truetype"); src: local("Spoqa Han Sans Bold"), url("/font/spoqa-hans/SpoqaHanSansBold.woff2") format("woff2"), url("/font/spoqa-hans/SpoqaHanSansBold.woff") format("woff"), url("/font/spoqa-hans/SpoqaHanSansBold.ttf") format("truetype");
} }
/* Noto Sans KR (Variable) */ /* Noto Sans KR (Variable)
- 본문 3순위 폴백이므로 로컬 설치본을 최우선 사용한다.
- 웹폰트는 한글 음절 전체(U+AC00D7A3)·라틴·기호만 남긴 woff2 서브셋( 1.1MB).
원본 VariableFont TTF(9.9MB) 전송 abort로 broken pipe 로그를 유발해 제거했다.
서브셋 재생성은 docs/font-subset.md 참고. */
@font-face { @font-face {
font-family: "Noto Sans KR"; font-family: "Noto Sans KR";
font-weight: 100 900; font-weight: 100 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
src: url("/font/noto-sans/NotoSansKR-VariableFont_wght.ttf") format("truetype"); src: local("Noto Sans KR"), local("NotoSansKR"), local("Noto Sans CJK KR"), url("/font/noto-sans/NotoSansKR-subset.woff2") format("woff2");
} }
:root { :root {
--primary-color: #0049b4; --primary-color: #0049b4;
@@ -817,6 +821,50 @@ hr {
flex-shrink: 0; flex-shrink: 0;
} }
.env-badge {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
background: var(--accent-orange);
color: var(--white);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.6;
}
@media (max-width: 1024px) {
.env-badge {
display: none;
}
}
.env-strip {
display: none;
}
@media (max-width: 1024px) {
.env-strip {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 16px;
background: var(--accent-orange);
color: var(--white);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
z-index: 1100;
pointer-events: none;
}
}
.logo-wrapper { .logo-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1188,7 +1236,7 @@ hr {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.mobile-drawer .drawer-welcome .btn-drawer-login:hover { .mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0, 65.7, 162); background: rgb(0%, 25.7647058824%, 63.5294117647%);
} }
.mobile-drawer .drawer-welcome.authenticated { .mobile-drawer .drawer-welcome.authenticated {
flex-direction: row; flex-direction: row;
@@ -2499,7 +2547,7 @@ hr {
color: #FFFFFF; color: #FFFFFF;
} }
.btn-success:hover { .btn-success:hover {
background: rgb(83.2897959184, 199.3102040816, 106.493877551); background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
transform: translateY(-3px); transform: translateY(-3px);
} }
.btn-danger { .btn-danger {
@@ -2507,7 +2555,7 @@ hr {
color: #FFFFFF; color: #FFFFFF;
} }
.btn-danger:hover { .btn-danger:hover {
background: rgb(255, 70.8, 70.8); background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-3px); transform: translateY(-3px);
} }
.btn-ghost { .btn-ghost {
@@ -2773,7 +2821,7 @@ hr {
.action-btn-delete:hover { .action-btn-delete:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1); box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9); background: rgb(100%, 34.862745098%, 34.862745098%);
} }
.action-btn-delete:active { .action-btn-delete:active {
transform: translateY(0); transform: translateY(0);
@@ -2891,7 +2939,7 @@ hr {
background: #a4d6ea; background: #a4d6ea;
} }
.btn-input-action.btn-change:hover { .btn-input-action.btn-change:hover {
background: rgb(131.6625, 199.4303571429, 226.5375); background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1); box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
} }
@@ -2966,7 +3014,7 @@ hr {
border: none; border: none;
} }
.btn-action-primary:hover { .btn-action-primary:hover {
background: rgb(31.8731707317, 92.7219512195, 205.7268292683); background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
transform: translateY(-2px); transform: translateY(-2px);
color: #fff; color: #fff;
} }
@@ -3016,7 +3064,7 @@ hr {
} }
.status-badge.status-processing { .status-badge.status-processing {
background: rgba(255, 217, 61, 0.1); background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0); color: rgb(86.7450980392%, 69.7537901759%, 0%);
} }
.status-badge.status-failed { .status-badge.status-failed {
background: rgba(255, 107, 107, 0.1); background: rgba(255, 107, 107, 0.1);
@@ -3056,7 +3104,7 @@ hr {
} }
.status-badge-header.status-processing { .status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1); background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0); color: rgb(86.7450980392%, 69.7537901759%, 0%);
} }
.badge-sm { .badge-sm {
@@ -4244,7 +4292,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover { .file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1); box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9); background: rgb(100%, 34.862745098%, 34.862745098%);
} }
.file-upload-wrapper .file-remove-btn:active { .file-upload-wrapper .file-remove-btn:active {
transform: translateY(0); transform: translateY(0);
@@ -4565,7 +4613,7 @@ select.form-control {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.form-actions--with-withdrawal .withdrawal-link:hover { .form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(210.2090909091, 232.6045454545, 242.9409090909); background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
} }
.form-actions--with-withdrawal .withdrawal-link img { .form-actions--with-withdrawal .withdrawal-link img {
width: 22px; width: 22px;
@@ -4720,7 +4768,7 @@ select.form-control {
text-decoration: underline; text-decoration: underline;
} }
.notice-content-box a:hover { .notice-content-box a:hover {
color: rgb(0, 65.7, 162); color: rgb(0%, 25.7647058824%, 63.5294117647%);
} }
.form-row--content .form-label-wrapper { .form-row--content .form-label-wrapper {
@@ -5656,7 +5704,7 @@ select.form-control {
font-size: 16px; font-size: 16px;
} }
.drawer-logout-btn:hover { .drawer-logout-btn:hover {
background: rgb(255, 70.8, 70.8); background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15); box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
} }
@@ -6369,7 +6417,7 @@ select.form-control {
color: #64748b; color: #64748b;
} }
.list-table-btn--default:hover { .list-table-btn--default:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429); background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
} }
.list-table-btn--primary { .list-table-btn--primary {
background-color: #ecf0fa; background-color: #ecf0fa;
@@ -6377,7 +6425,7 @@ select.form-control {
color: #2a69de; color: #2a69de;
} }
.list-table-btn--primary:hover { .list-table-btn--primary:hover {
background-color: rgb(216.7625, 224.8125, 244.9375); background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
} }
.list-table-btn--secondary { .list-table-btn--secondary {
background-color: #f5f5f4; background-color: #f5f5f4;
@@ -6385,7 +6433,7 @@ select.form-control {
color: #64748b; color: #64748b;
} }
.list-table-btn--secondary:hover { .list-table-btn--secondary:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429); background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
} }
.list-table-btn--danger { .list-table-btn--danger {
background-color: #fbe7e9; background-color: #fbe7e9;
@@ -6393,7 +6441,7 @@ select.form-control {
color: #bb1026; color: #bb1026;
} }
.list-table-btn--danger:hover { .list-table-btn--danger:hover {
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143); background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
} }
.table-pagination { .table-pagination {
@@ -7043,7 +7091,7 @@ select.form-control {
.alert.alert-error { .alert.alert-error {
background: rgba(255, 107, 107, 0.1); background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3); border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(255, 70.8, 70.8); color: rgb(100%, 27.7647058824%, 27.7647058824%);
align-items: center; align-items: center;
} }
.alert.alert-error svg { .alert.alert-error svg {
@@ -7057,7 +7105,7 @@ select.form-control {
.alert.alert-success { .alert.alert-success {
background: rgba(107, 207, 127, 0.1); background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3); border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082); color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
} }
.alert.alert-info { .alert.alert-info {
background: rgba(0, 73, 180, 0.1); background: rgba(0, 73, 180, 0.1);
@@ -11428,10 +11476,10 @@ body.index-page-body {
line-height: 20px; line-height: 20px;
} }
.login-button:hover { .login-button:hover {
background: rgb(25.65, 70.3, 173.85); background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
} }
.login-button:active { .login-button:active {
background: rgb(24.3, 66.6, 164.7); background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
} }
.login-button:disabled { .login-button:disabled {
opacity: 0.6; opacity: 0.6;
@@ -11474,10 +11522,10 @@ body.index-page-body {
border-bottom-right-radius: 8px; border-bottom-right-radius: 8px;
} }
.login-links-container .link-btn:hover { .login-links-container .link-btn:hover {
background: rgb(220.61, 227.85, 245.95); background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
} }
.login-links-container .link-btn:active { .login-links-container .link-btn:active {
background: rgb(205.22, 215.7, 241.9); background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
} }
.login-alert { .login-alert {
@@ -11986,12 +12034,12 @@ body.index-page-body {
} }
.auth-request-button:hover, .auth-request-button:hover,
.auth-verify-button:hover { .auth-verify-button:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991); background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
transform: none !important; transform: none !important;
} }
.auth-request-button:active, .auth-request-button:active,
.auth-verify-button:active { .auth-verify-button:active {
background: rgb(21.411588785, 146.3125233645, 233.148411215); background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
} }
.auth-request-button:disabled, .auth-request-button:disabled,
.auth-verify-button:disabled { .auth-verify-button:disabled {
@@ -12040,10 +12088,10 @@ body.index-page-body {
background: #f0f2f5; background: #f0f2f5;
} }
.account-recovery-card .form-actions .cancel-button:hover { .account-recovery-card .form-actions .cancel-button:hover {
background: rgb(225.45, 229.39, 235.3); background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
} }
.account-recovery-card .form-actions .cancel-button:active { .account-recovery-card .form-actions .cancel-button:active {
background: rgb(210.9, 216.78, 225.6); background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
} }
.account-recovery-card .form-actions .submit-button { .account-recovery-card .form-actions .submit-button {
color: #FFFFFF; color: #FFFFFF;
@@ -12053,7 +12101,7 @@ body.index-page-body {
background: rgb(6, 54, 125); background: rgb(6, 54, 125);
} }
.account-recovery-card .form-actions .submit-button:active { .account-recovery-card .form-actions .submit-button:active {
background: rgb(0, 65.7, 162); background: rgb(0%, 25.7647058824%, 63.5294117647%);
} }
.account-recovery-card .form-actions .submit-button:disabled { .account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6; opacity: 0.6;
@@ -12289,7 +12337,7 @@ body.index-page-body {
transition: color 0.3s ease; transition: color 0.3s ease;
} }
.result-info-box .info-text .info-link:hover { .result-info-box .info-text .info-link:hover {
color: rgb(0, 65.7, 162); color: rgb(0%, 25.7647058824%, 63.5294117647%);
} }
@media (max-width: 576px) { @media (max-width: 576px) {
.result-info-box .info-text { .result-info-box .info-text {
@@ -17489,7 +17537,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.btn-copy-action:hover { .btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375); background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.btn-copy-action { .btn-copy-action {
@@ -17516,7 +17564,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.btn-view-secret:hover { .btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738); background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
} }
.btn-view-secret svg { .btn-view-secret svg {
width: 20px; width: 20px;
@@ -17701,7 +17749,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px; border-radius: 8px;
} }
.btn-copy-action:hover { .btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375); background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
} }
.btn-view-secret { .btn-view-secret {
width: 100% !important; width: 100% !important;
@@ -17717,7 +17765,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px; height: 16px;
} }
.btn-view-secret:hover { .btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738); background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
} }
#revealedSecretBox { #revealedSecretBox {
width: 100%; width: 100%;
@@ -17990,7 +18038,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0; flex-shrink: 0;
} }
.detail-wrap .dt-btn-copy:hover { .detail-wrap .dt-btn-copy:hover {
background: rgb(189.6, 230.0857142857, 255); background: rgb(74.3529411765%, 90.2296918768%, 100%);
} }
.detail-wrap .dt-btn-copy svg { .detail-wrap .dt-btn-copy svg {
color: #2a69de; color: #2a69de;
@@ -18069,6 +18117,10 @@ input[type=checkbox]:checked + .custom-checkbox {
padding-right: 20px; padding-right: 20px;
flex: 1; flex: 1;
} }
.detail-wrap .dt-api-name:hover {
color: #2a69de;
text-decoration: underline;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.detail-wrap .dt-api-name { .detail-wrap .dt-api-name {
font-size: 14px; font-size: 14px;
@@ -18167,7 +18219,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.detail-wrap .dt-btn-gray:hover { .detail-wrap .dt-btn-gray:hover {
background: rgb(170.6589473684, 183.4378947368, 193.6610526316); background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
} }
.detail-wrap .dt-btn-red { .detail-wrap .dt-btn-red {
width: 156px; width: 156px;
@@ -18185,7 +18237,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.detail-wrap .dt-btn-red:hover { .detail-wrap .dt-btn-red:hover {
background: rgb(255, 70.0915337423, 64.24); background: rgb(100%, 27.4868759774%, 25.1921568627%);
} }
.detail-wrap .dt-btn-blue { .detail-wrap .dt-btn-blue {
width: 156px; width: 156px;
@@ -19759,7 +19811,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.btn-inquiry-list:hover { .btn-inquiry-list:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783); background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
} }
.btn-inquiry-list:active { .btn-inquiry-list:active {
transform: scale(0.98); transform: scale(0.98);
@@ -19792,7 +19844,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.btn-inquiry-edit:hover { .btn-inquiry-edit:hover {
background: rgb(0, 69.35, 171); background: rgb(0%, 27.1960784314%, 67.0588235294%);
} }
.btn-inquiry-edit:active { .btn-inquiry-edit:active {
transform: scale(0.98); transform: scale(0.98);
@@ -19825,7 +19877,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.btn-inquiry-delete:hover { .btn-inquiry-delete:hover {
background: rgb(217.9841772152, 41.3658227848, 58.2873417722); background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
} }
.btn-inquiry-delete:active { .btn-inquiry-delete:active {
transform: scale(0.98); transform: scale(0.98);
@@ -19897,7 +19949,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px; margin-left: 8px;
} }
.file-upload-inline .btn-remove-file-inline:hover { .file-upload-inline .btn-remove-file-inline:hover {
background: rgb(209.4151898734, 36.2848101266, 52.8721518987); background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
} }
.file-upload-inline .btn-remove-file-inline svg { .file-upload-inline .btn-remove-file-inline svg {
width: 12px; width: 12px;
@@ -19929,7 +19981,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.file-upload-inline .btn-file-attach:hover { .file-upload-inline .btn-file-attach:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991); background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
} }
.file-upload-inline .btn-file-attach svg { .file-upload-inline .btn-file-attach svg {
width: 22px; width: 22px;
@@ -19989,7 +20041,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none; border: none;
} }
.inquiry-form-container .form-actions .btn-secondary:hover { .inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783); background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
} }
.inquiry-form-container .form-actions .btn-primary { .inquiry-form-container .form-actions .btn-primary {
background: #0049b4; background: #0049b4;
@@ -19997,7 +20049,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none; border: none;
} }
.inquiry-form-container .form-actions .btn-primary:hover { .inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0, 69.35, 171); background: rgb(0%, 27.1960784314%, 67.0588235294%);
} }
.inquiry-form-container .file-upload-inline .file-input-display { .inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px; min-height: 50px;
@@ -20786,7 +20838,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer; cursor: pointer;
} }
.djb-board-write-container .form-actions .btn-submit:hover { .djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(33.643902439, 97.8731707317, 217.156097561); background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit { .djb-board-write-container .form-actions .btn-submit {
@@ -21322,7 +21374,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.org-file-remove:hover { .org-file-remove:hover {
background: rgb(255, 70.8, 70.8); background: rgb(100%, 27.7647058824%, 27.7647058824%);
} }
.org-file-notice { .org-file-notice {
@@ -22334,7 +22386,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
.status-indicator.status-active { .status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1); background-color: rgba(107, 207, 127, 0.1);
color: rgb(83.2897959184, 199.3102040816, 106.493877551); color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
} }
.status-indicator.status-active .status-dot { .status-indicator.status-active .status-dot {
background-color: #6BCF7F; background-color: #6BCF7F;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
View File
@@ -321,6 +321,14 @@ function insertImageAsDataUri(file, $editor) {
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
/**
* 정규식 기반 정리를 적용할 HTML 최대 길이(문자).
* 아래 VML/네임스페이스 정리 정규식은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
* 비정상적으로 큰 입력에서는 정규식 단계를 건너뛴다. data-uri 이미지를 포함한 일반적인
* Office 붙여넣기는 이 한도 아래다.
*/
var MAX_REGEX_CLEAN_LENGTH = 500000;
/** /**
* 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용) * 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용)
* - 불필요한 Office 속성 제거 * - 불필요한 Office 속성 제거
@@ -358,6 +366,14 @@ function cleanPastedHtml(html) {
// VML 태그 제거 (v:, o:, w: 등) // VML 태그 제거 (v:, o:, w: 등)
var cleanedHtml = $temp.html(); var cleanedHtml = $temp.html();
// 한도 초과 시 정규식 정리를 건너뛴다 — DOM 정리(이미지 속성 제거)는 이미 끝났으므로
// 붙여넣기 자체는 동작하고, Office 네임스페이스 잔여물만 남는다. (브라우저 멈춤 방지)
if (cleanedHtml.length > MAX_REGEX_CLEAN_LENGTH) {
console.warn('붙여넣기 HTML이 너무 커서 Office 태그 정리를 건너뜁니다. length=' + cleanedHtml.length);
return cleanedHtml;
}
cleanedHtml = cleanedHtml.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, ''); cleanedHtml = cleanedHtml.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, ''); cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, ''); cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
+60 -10
View File
@@ -12,7 +12,7 @@
var undefined; var undefined;
/** Used as the semantic version number. */ /** Used as the semantic version number. */
var VERSION = '4.17.21'; var VERSION = '4.18.1';
/** Used as the size to enable large array optimizations. */ /** Used as the size to enable large array optimizations. */
var LARGE_ARRAY_SIZE = 200; var LARGE_ARRAY_SIZE = 200;
@@ -20,7 +20,8 @@
/** Error message constants. */ /** Error message constants. */
var CORE_ERROR_TEXT = 'Unsupported core-js use. Try https://npms.io/search?q=ponyfill.', var CORE_ERROR_TEXT = 'Unsupported core-js use. Try https://npms.io/search?q=ponyfill.',
FUNC_ERROR_TEXT = 'Expected a function', FUNC_ERROR_TEXT = 'Expected a function',
INVALID_TEMPL_VAR_ERROR_TEXT = 'Invalid `variable` option passed into `_.template`'; INVALID_TEMPL_VAR_ERROR_TEXT = 'Invalid `variable` option passed into `_.template`',
INVALID_TEMPL_IMPORTS_ERROR_TEXT = 'Invalid `imports` option passed into `_.template`';
/** Used to stand-in for `undefined` hash values. */ /** Used to stand-in for `undefined` hash values. */
var HASH_UNDEFINED = '__lodash_hash_undefined__'; var HASH_UNDEFINED = '__lodash_hash_undefined__';
@@ -1752,6 +1753,10 @@
* embedded Ruby (ERB) as well as ES2015 template strings. Change the * embedded Ruby (ERB) as well as ES2015 template strings. Change the
* following template settings to use alternative delimiters. * following template settings to use alternative delimiters.
* *
* **Security:** See
* [threat model](https://github.com/lodash/lodash/blob/main/threat-model.md)
* `_.template` is insecure and will be removed in v5.
*
* @static * @static
* @memberOf _ * @memberOf _
* @type {Object} * @type {Object}
@@ -2300,7 +2305,7 @@
* @name has * @name has
* @memberOf SetCache * @memberOf SetCache
* @param {*} value The value to search for. * @param {*} value The value to search for.
* @returns {number} Returns `true` if `value` is found, else `false`. * @returns {boolean} Returns `true` if `value` is found, else `false`.
*/ */
function setCacheHas(value) { function setCacheHas(value) {
return this.__data__.has(value); return this.__data__.has(value);
@@ -3766,7 +3771,7 @@
if (isArray(iteratee)) { if (isArray(iteratee)) {
return function(value) { return function(value) {
return baseGet(value, iteratee.length === 1 ? iteratee[0] : iteratee); return baseGet(value, iteratee.length === 1 ? iteratee[0] : iteratee);
} };
} }
return iteratee; return iteratee;
}); });
@@ -4370,8 +4375,34 @@
*/ */
function baseUnset(object, path) { function baseUnset(object, path) {
path = castPath(path, object); path = castPath(path, object);
object = parent(object, path);
return object == null || delete object[toKey(last(path))]; // Prevent prototype pollution:
// https://github.com/lodash/lodash/security/advisories/GHSA-xxjr-mmjv-4gpg
// https://github.com/lodash/lodash/security/advisories/GHSA-f23m-r3pf-42rh
var index = -1,
length = path.length;
if (!length) {
return true;
}
while (++index < length) {
var key = toKey(path[index]);
// Always block "__proto__" anywhere in the path if it's not expected
if (key === '__proto__' && !hasOwnProperty.call(object, '__proto__')) {
return false;
}
// Block constructor/prototype as non-terminal traversal keys to prevent
// escaping the object graph into built-in constructors and prototypes.
if ((key === 'constructor' || key === 'prototype') && index < length - 1) {
return false;
}
}
var obj = parent(object, path);
return obj == null || delete obj[toKey(last(path))];
} }
/** /**
@@ -6922,7 +6953,7 @@
/** /**
* Creates an array with all falsey values removed. The values `false`, `null`, * Creates an array with all falsey values removed. The values `false`, `null`,
* `0`, `""`, `undefined`, and `NaN` are falsey. * `0`, `-0`, `0n`, `""`, `undefined`, and `NaN` are falsy.
* *
* @static * @static
* @memberOf _ * @memberOf _
@@ -7461,7 +7492,7 @@
while (++index < length) { while (++index < length) {
var pair = pairs[index]; var pair = pairs[index];
result[pair[0]] = pair[1]; baseAssignValue(result, pair[0], pair[1]);
} }
return result; return result;
} }
@@ -14121,6 +14152,8 @@
* **Note:** JavaScript follows the IEEE-754 standard for resolving * **Note:** JavaScript follows the IEEE-754 standard for resolving
* floating-point values which can produce unexpected results. * floating-point values which can produce unexpected results.
* *
* **Note:** If `lower` is greater than `upper`, the values are swapped.
*
* @static * @static
* @memberOf _ * @memberOf _
* @since 0.7.0 * @since 0.7.0
@@ -14134,9 +14167,16 @@
* _.random(0, 5); * _.random(0, 5);
* // => an integer between 0 and 5 * // => an integer between 0 and 5
* *
* // when lower is greater than upper the values are swapped
* _.random(5, 0);
* // => an integer between 0 and 5
*
* _.random(5); * _.random(5);
* // => also an integer between 0 and 5 * // => also an integer between 0 and 5
* *
* _.random(-5);
* // => an integer between -5 and 0
*
* _.random(5, true); * _.random(5, true);
* // => a floating-point number between 0 and 5 * // => a floating-point number between 0 and 5
* *
@@ -14738,6 +14778,10 @@
* properties may be accessed as free variables in the template. If a setting * properties may be accessed as free variables in the template. If a setting
* object is given, it takes precedence over `_.templateSettings` values. * object is given, it takes precedence over `_.templateSettings` values.
* *
* **Security:** `_.template` is insecure and should not be used. It will be
* removed in Lodash v5. Avoid untrusted input. See
* [threat model](https://github.com/lodash/lodash/blob/main/threat-model.md).
*
* **Note:** In the development build `_.template` utilizes * **Note:** In the development build `_.template` utilizes
* [sourceURLs](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl) * [sourceURLs](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl)
* for easier debugging. * for easier debugging.
@@ -14845,12 +14889,18 @@
options = undefined; options = undefined;
} }
string = toString(string); string = toString(string);
options = assignInWith({}, options, settings, customDefaultsAssignIn); options = assignWith({}, options, settings, customDefaultsAssignIn);
var imports = assignInWith({}, options.imports, settings.imports, customDefaultsAssignIn), var imports = assignWith({}, options.imports, settings.imports, customDefaultsAssignIn),
importsKeys = keys(imports), importsKeys = keys(imports),
importsValues = baseValues(imports, importsKeys); importsValues = baseValues(imports, importsKeys);
arrayEach(importsKeys, function(key) {
if (reForbiddenIdentifierChars.test(key)) {
throw new Error(INVALID_TEMPL_IMPORTS_ERROR_TEXT);
}
});
var isEscaping, var isEscaping,
isEvaluating, isEvaluating,
index = 0, index = 0,
@@ -87,11 +87,18 @@
url('/font/spoqa-hans/SpoqaHanSansBold.ttf') format('truetype'); url('/font/spoqa-hans/SpoqaHanSansBold.ttf') format('truetype');
} }
/* Noto Sans KR (Variable) */ /* Noto Sans KR (Variable)
- 본문 3순위 폴백이므로 로컬 설치본을 최우선 사용한다.
- 웹폰트는 한글 음절 전체(U+AC00–D7A3)·라틴·기호만 남긴 woff2 서브셋(약 1.1MB).
원본 VariableFont TTF(9.9MB)는 전송 중 abort로 broken pipe 로그를 유발해 제거했다.
서브셋 재생성은 docs/font-subset.md 참고. */
@font-face { @font-face {
font-family: 'Noto Sans KR'; font-family: 'Noto Sans KR';
font-weight: 100 900; font-weight: 100 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
src: url('/font/noto-sans/NotoSansKR-VariableFont_wght.ttf') format('truetype'); src: local('Noto Sans KR'),
local('NotoSansKR'),
local('Noto Sans CJK KR'),
url('/font/noto-sans/NotoSansKR-subset.woff2') format('woff2');
} }
@@ -156,6 +156,50 @@
} }
} }
// 활성 Spring 프로파일 표시 (prod 제외)
// 데스크톱: 로고 옆 인라인 뱃지 / 모바일·태블릿: 최상단 floating 바(fixed, 레이아웃 미영향)
.env-badge {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
background: var(--accent-orange);
color: var(--white);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.6;
@media (max-width: 1024px) {
display: none;
}
}
.env-strip {
display: none;
@media (max-width: 1024px) {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 16px;
background: var(--accent-orange);
color: var(--white);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
z-index: 1100;
pointer-events: none;
}
}
// Logo Wrapper // Logo Wrapper
.logo-wrapper { .logo-wrapper {
display: flex; display: flex;
@@ -1198,6 +1198,11 @@
padding-right: 20px; padding-right: 20px;
flex: 1; flex: 1;
&:hover {
color: #2a69de;
text-decoration: underline;
}
@media (max-width: 768px) { @media (max-width: 768px) {
font-size: 14px; font-size: 14px;
} }
@@ -647,16 +647,23 @@
return (CURRENT_RESPONSE_TYPE || '').toLowerCase() === 'mock'; return (CURRENT_RESPONSE_TYPE || '').toLowerCase() === 'mock';
} }
// sample/mock 응답유형은 실제 인증이 성립하지 않는다.
// - sample: ApiTesterFilter 가 저장된 샘플 응답을 그대로 반환(실호출 없음)
// - mock : mock 서버로만 forward 되고 토큰도 고정값 즉시 발급(Secret 검증 없음)
// → 토큰 발급 왕복·Secret 입력 없이 바로 인증 상태로 진행한다.
// (responseType 미지정은 필터와 동일하게 sample 로 간주)
function isNoAuthApi() {
const type = (CURRENT_RESPONSE_TYPE || '').toLowerCase();
return type === '' || type === 'sample' || type === 'mock';
}
function authorizeApp(secret) { function authorizeApp(secret) {
const gw = window.__djbGateway || {}; const gw = window.__djbGateway || {};
if (!window.ui) return; if (!window.ui) return;
// 응답유형 mock: 실 Secret 검증 없이 샘플 SecretKey + mock 토큰으로 인증 진행 — toast 안내 // sample/mock: 실제 인증 절차 생략하고 고정 토큰/키를 즉시 주입 — toast 안내
if (isMockApi()) { if (isNoAuthApi()) {
if (typeof djbToast === 'function') { authorizeWithoutAuth(secret, gw);
djbToast('Mock 서버로 호출되므로 별도 Secret Key 로 인증 절차를 진행합니다.', { type: 'info', duration: 5000 });
}
proceedAuthorize(secret, secret.clientSecret || 'mock-secret', gw);
return; return;
} }
@@ -669,6 +676,34 @@
proceedAuthorize(secret, secret.clientSecret, gw); proceedAuthorize(secret, secret.clientSecret, gw);
} }
// sample/mock 전용 — 토큰 발급/Secret 입력 없이 Swagger authorize 에 바로 주입.
// 토큰 값은 컨텍스트의 mockAccessToken(= ApiTesterFilter 가 발급하는 값과 동일).
function authorizeWithoutAuth(secret, gw) {
if (typeof djbToast === 'function') {
djbToast(isMockApi()
? 'Mock 응답 API 는 별도 인증 절차 없이 바로 호출됩니다.'
: '샘플 응답 API 는 별도 인증 절차 없이 바로 호출됩니다.', { type: 'info', duration: 5000 });
}
if (secret.authType === 'OAUTH') {
window.ui.authActions.authorize({
djbOAuth: {
name: 'djbOAuth',
schema: { type: 'apiKey', in: 'header', name: gw.oauthTokenHeader },
value: 'Bearer ' + (gw.mockAccessToken || 'sample-access-token')
}
});
} else if (secret.authType === 'API_KEY') {
window.ui.authActions.authorize({
djbApiKey: {
name: 'djbApiKey',
schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader },
value: secret.clientSecret || gw.mockAccessToken || 'sample-api-key'
}
});
}
refreshSnippetPanel();
}
// Client Secret 수동 입력 팝업 (포탈에는 이미 삭제된 경우) // Client Secret 수동 입력 팝업 (포탈에는 이미 삭제된 경우)
function promptClientSecret(secret, gw) { function promptClientSecret(secret, gw) {
if (typeof customPopups === 'undefined' || !customPopups.showPasswordInput) { if (typeof customPopups === 'undefined' || !customPopups.showPasswordInput) {
@@ -61,6 +61,7 @@
<form id="searchForm" th:action="@{/apis}" method="get"> <form id="searchForm" th:action="@{/apis}" method="get">
<input type="hidden" name="groupIds" <input type="hidden" name="groupIds"
th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" /> th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" />
<input type="hidden" name="page" value="1" />
<input type="text" class="search-input" name="keyword" th:value="${search.keyword}" <input type="text" class="search-input" name="keyword" th:value="${search.keyword}"
placeholder="검색어를 입력하세요."> placeholder="검색어를 입력하세요.">
<button type="submit" class="search-submit-btn" aria-label="검색"> <button type="submit" class="search-submit-btn" aria-label="검색">
@@ -99,13 +100,17 @@
<!-- API Image (Bottom Right) --> <!-- API Image (Bottom Right) -->
<div class="api-card-image"> <div class="api-card-image">
<img th:if="${api.mainIcon}" th:src="${api.mainIcon}" th:alt="${api.apiGroupName}" <img th:if="${api.apiGroupId}" th:src="@{/api-services/{id}/icon(id=${api.apiGroupId})}"
onerror="this.style.display='none'"> th:alt="${api.apiGroupName}" onerror="this.style.display='none'">
</div> </div>
</div> </div>
</div> </div>
<!-- Pagination -->
<div class="pagination" th:if="${apis != null and !apis.isEmpty()}"
th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<!-- Empty State --> <!-- Empty State -->
<div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}"> <div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}">
<div class="empty-icon">🔍</div> <div class="empty-icon">🔍</div>
@@ -121,6 +126,14 @@
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
// fragment/pagination.html 의 인라인 onclick 에서 호출 (전역 스코프 필요)
window.fn_select_page = function (pageNo) {
const form = document.getElementById('searchForm');
const pageInput = form.querySelector('input[name="page"]');
if (pageInput) pageInput.value = pageNo;
form.submit();
};
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// DOM Elements // DOM Elements
const sidebar = document.getElementById('apiSidebar'); const sidebar = document.getElementById('apiSidebar');
@@ -128,6 +141,7 @@
const mobileOverlay = document.getElementById('mobileOverlay'); const mobileOverlay = document.getElementById('mobileOverlay');
const searchForm = document.getElementById('searchForm'); const searchForm = document.getElementById('searchForm');
const groupIdsInput = searchForm.querySelector('input[name="groupIds"]'); const groupIdsInput = searchForm.querySelector('input[name="groupIds"]');
const pageInput = searchForm.querySelector('input[name="page"]');
const searchInput = searchForm.querySelector('input[name="keyword"]'); const searchInput = searchForm.querySelector('input[name="keyword"]');
// API Card Click Handlers // API Card Click Handlers
@@ -230,6 +244,9 @@
groupIdsInput.value = ''; groupIdsInput.value = '';
} }
// 카테고리 변경 시 1페이지로 리셋
if (pageInput) pageInput.value = '1';
// Submit form // Submit form
searchForm.submit(); searchForm.submit();
}); });
@@ -239,6 +256,7 @@
searchInput.addEventListener('keypress', function (e) { searchInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (pageInput) pageInput.value = '1';
searchForm.submit(); searchForm.submit();
} }
}); });
@@ -113,9 +113,12 @@
<label for="file" class="file-upload-btn"> <label for="file" class="file-upload-btn">
파일 선택 파일 선택
</label> </label>
<input type="file" id="file" name="file" style="display: none;" /> <input type="file" id="file" name="files"
th:accept="${isInternalUser ? '*' : '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.hwp,.gif,.jpg,.jpeg,.png'}"
style="display: none;" />
</div> </div>
<small class="form-help-text" style="margin-top: 8px; display: block; color: #8c959f;"> <small class="form-help-text" style="margin-top: 8px; display: block; color: #8c959f;">
<span th:text="'첨부파일은 ' + ${@portalProperties.file.maxSize} + ' 이내로 등록해 주세요. '">첨부파일은 8MB 이내로 등록해 주세요. </span>
문서파일(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp)과 이미지 파일(gif, jpg, png)만 등록 가능합니다. 문서파일(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp)과 이미지 파일(gif, jpg, png)만 등록 가능합니다.
</small> </small>
</div> </div>
@@ -217,6 +220,14 @@
} }
/*[/]*/ /*[/]*/
const maxSizeBytes = (window.PORTAL_CONFIG && window.PORTAL_CONFIG.file)
? window.PORTAL_CONFIG.file.maxSizeBytes : 0;
if (maxSizeBytes > 0 && file.size > maxSizeBytes) {
customPopups.showAlert('첨부파일은 ' + window.PORTAL_CONFIG.file.maxSize + ' 이내로 등록해 주세요.');
fileInput.value = '';
return;
}
if (fileDisplayText && fileInputDisplay && btnRemoveFile) { if (fileDisplayText && fileInputDisplay && btnRemoveFile) {
fileDisplayText.textContent = file.name; fileDisplayText.textContent = file.name;
fileDisplayText.classList.add('has-file'); fileDisplayText.classList.add('has-file');
@@ -238,10 +238,8 @@
</span> </span>
</p> </p>
<div class="card-illustration"> <div class="card-illustration">
<!-- 서비스별 아이콘 매핑 - base64 인코딩된 이미지 사용 --> <!-- 서비스별 아이콘: base64 인라인 대신 그룹 id 기준 스트리밍 엔드포인트(캐시 가능) 사용 -->
<img <img th:src="@{/api-services/{id}/icon(id=${service.id})}" th:alt="${service.groupName}">
th:src="${service.mainIcon != null and !#strings.isEmpty(service.mainIcon)} ? ${service.mainIcon} : @{/img/api_icon_default.png}"
th:alt="${service.groupName}">
</div> </div>
</div> </div>
</div> </div>
@@ -560,9 +558,6 @@
</th:block> </th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<!-- 메인 페이지 전용 스크립트 모듈 추가 -->
<script th:src="@{/js/main.js}"></script>
<!-- 로그인 후 처리 스크립트 --> <!-- 로그인 후 처리 스크립트 -->
<script th:src="@{/js/login-success-handler.js}"></script> <script th:src="@{/js/login-success-handler.js}"></script>
<script th:inline="javascript"> <script th:inline="javascript">

Some files were not shown because too many files have changed in this diff Show More