41 Commits

Author SHA1 Message Date
Rinjae(gf63) 1a8531e064 - thymeleaf 버전 3.1.5.RELEASE 상향 및 주석 반영 수정
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- Spring Boot CVE 6~11 건 억제 사유 추가: 배포별 영향 검토
- eai 오탐(CVE-2019-15079) 및 SBA client 오탐 억제 추가
2026-09-15 16:21:41 +09:00
Rinjae(gf63) 24103d47f9 - 테스트 코드 리팩토링: LocalDateTime -> Month 상수 활용
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- assertEquals(false) -> assertFalse로 개선
- Boolean.FALSE 사용으로 코드 명확성 향상
2026-09-15 16:06:25 +09:00
Rinjae(gf63) f7531412be - 약관 컨트롤러/서비스 테스트 추가: 동의서 노출 및 동작 방식 검증
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- 사용자 등록 컨트롤러 테스트: 약관 노출 항목 설정 반영 확인
- 법인 가입/사용자 관리 관련 약관 동작 테스트 추가
2026-09-15 15:33:41 +09:00
Rinjae(gf63) ddca2a8ea4 - 약관 동의서 노출 및 동작 방식 관리 기능 추가
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- '전체 동의' 및 약관 항목별 활성화 설정 반영
- 사용자 타입 및 약관 페이지 구성에 따른 렌더링 로직 개선
2026-09-15 13:42:27 +09:00
Rinjae(gf63) 094336ebf1 - Gradle 실행 설정 추가 (clean, compileJava)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- API 편집기 닫기 전 변경사항 알림/확인 로직 추가
- 저장 성공/초기 로드 시점 기준 스냅샷 로직 및 UI 처리를 반영
2026-09-11 18:38:50 +09:00
Rinjae a310b671e2 API 통계 날짜 선택 로직 및 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 최대 40일 범위 제한 및 1년 조회 가능 기간 검증
- Date-range Picker UI 추가 및 서버 동기화 로직 구현
- Controller, DTO 유효성 테스트 및 Playwright 테스트 작성
2026-09-09 16:25:00 +09:00
Rinjae d9373b00f0 Moment.js 업그레이드 및 호환성 테스트 추가:
- 최신 버전(2.30.1)으로 업데이트 및 CVE-2022-24785 대응
- 브라우저 기반 date-range 동작 검증 및 Playwright 테스트 추가
- 윤년/서머타임 처리 및 CommonJS 보안 회귀 검증
2026-09-09 16:00:13 +09:00
Rinjae 09bc3a65f5 API Selector 전역변수 사용 수정: window → globalThis
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 14:36:55 +09:00
Rinjae 1d81681e70 API 목록 페이징 로직 테스트 및 페이지 크기 설정 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 기본 페이지 크기 설정 ApiListProperties 도입
- Controller, 템플릿, JS에 설정값 연결 및 테스트 추가
2026-09-09 12:02:08 +09:00
Rinjae d4bafa706c 로고 파일 이름 변경: logo-jjb_white.pnglogo-jjb-white.png
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 10:00:35 +09:00
Rinjae 60f9f89d90 2FA 채널 정렬 로직 추가: SMS를 우선 표시 및 기본 선택 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 09:51:44 +09:00
Rinjae 8ecfaf4945 쿠키 및 CSRF 토큰 유지 여부 검사 스크립트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 새로운 익명 세션에서 두 서버 간 응답 비교 기능 구현
- Python 기반 검사기 `cookie.py` 작성 및 주요 옵션 제공
2026-09-08 10:31:58 +09:00
Rinjae fa736a36ab Thymeleaf 호환성 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Mvc/Form/Security/Expression 관련 템플릿 및 테스트 구현
- 의존성 검증 및 업그레이드 스크립트 추가 (Thymeleaf 3.1.5 적용)
2026-09-07 15:29:45 +09:00
Rinjae b7869ad6e6 OHS 설정 업데이트:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 정적 리소스 경로 `/apps/portal-static`으로 변경
- favicon 및 SetHandler 설정 추가
2026-09-07 11:04:20 +09:00
Rinjae 982187298c 정적 리소스 서빙 및 빌드 프로세스 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OHS 환경 대응 정적자원 해시 버전닝 OFF 설정 반영
- eapim-portal-static.zip 생성 및 CI/CD 추가
- OHS 설정 샘플 가이드 추가
2026-09-07 10:55:43 +09:00
Rinjae 8118dba74d CSS 소스맵 업데이트: 최신 Sass 구성 반영 및 맵핑 갱신
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-03 17:05:47 +09:00
Rinjae-gf63 97489d0361 - PasswordDecryptFilter에 /internal/** 경로 제외 처리 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 내부 API 요청 필터 동작/암호화 정책 충돌 방지
2026-09-03 09:19:46 +09:00
Rinjae-gf63 924c00968c - 멀티파트 요청 지원 복호화 래퍼 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PasswordDecryptFilter 순서 조정(MultipartFilter 이후 실행)
- 필터와 XSS Escape 필터 순서 정보 및 주석 보완
2026-09-02 18:35:44 +09:00
Rinjae 53c3b0ee7e 포탈 암호화 모듈 추가: forge-crypto.min.js 라이브러리 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-02 16:59:25 +09:00
Rinjae b2787882bf 비밀번호 암호화 로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- name 재지정 및 hidden 필드로 봉투 전송 처리 추가
- 폼 제출 후 원상복구 스케줄링 로직 구현
2026-09-02 09:50:56 +09:00
Rinjae aafa7ac351 NotiTestController 요청 매핑 경로 수정: //notitest/djb/notitest
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-02 09:35:50 +09:00
Rinjae b7477e10a1 feats/security 브랜치 병합 - 비밀번호 전송 암호화(RSA-OAEP + AES-GCM) 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-01 15:08:21 +09:00
Rinjae 11f1dae1f3 Thymeleaf 프래그먼트 경로 표기 오류 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모든 th:replace/th:include 경로 ~{}로 변경
2026-09-01 15:07:23 +09:00
Rinjae 59c379b22c 비밀번호 전송 암호화 기능 추가:
- RSA-OAEP + AES-GCM 기반 비밀번호 전송 암호화 모듈 구현
- 클라이언트 공개키 발급 및 복호화 처리 로직 개발
- 암호화 설정 속성 및 요청별/세션별/서버별 키 보관소 구성
2026-09-01 15:06:30 +09:00
Rinjae d275aa12c4 Admin GatewayClient 다중 base URL 및 failover 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- admin.base-url 다중 주소 지원 및 순차 failover 구현
- RestTemplate 타임아웃 설정 변경 (연결/읽기: 5초로 단축)
2026-09-01 14:50:39 +09:00
Rinjae c732f1e1b2 API 상태 타임라인 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 진행 상태에 따른 상태 색 및 한글 라벨 표시 추가
2026-09-01 14:12:17 +09:00
Rinjae 9e32502ee3 WebLogic 라이브러리 안정성 검증 로직 강화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- jboss-logging/Guava 클래스 로드시충돌 점검 추가
- ReadinessController에 관련 체크 로직 반영
2026-08-27 16:47:15 +09:00
Rinjae 2d0ff2bf84 템플릿 엔진 상태 점검 추가 - Thymeleaf 구성 검증 로직 반영
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:44:57 +09:00
Rinjae 21940a3e18 WebLogic jackson 버전 충돌 방지 설정 추가 - jackon-module-jaxb-annotations 명시 의존성 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:40:05 +09:00
Rinjae 2e517a1e08 WebLogic 라이브러리 4개 우선 로드 설정 추가 - IllegalAccessError/NoSuchMethodError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:25:30 +09:00
Rinjae 10c5e51162 WebLogic commons-lang3 우선 로드 설정 추가 - IllegalAccessError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:20:24 +09:00
Rinjae 69e5126de6 WebLogic commons-fileupload 우선 로드 설정 추가 - NoSuchMethodError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 15:55:37 +09:00
Rinjae d044ebd157 Merge branch 'feats/security'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 15:43:42 +09:00
Rinjae d6b3a44d61 Merge branch 'master' into feats/security
# Conflicts:
#	src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java
#	src/main/resources/templates/views/apps/webhook/webhookList.html
#	src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
#	src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
2026-08-27 13:23:15 +09:00
Rinjae e57f2fe72f WIP: webhook 작업 임시 커밋 (develop 전환 전 안전 확보) 2026-08-27 10:59:22 +09:00
Rinjae dfb37c18b9 commons-lang3 3.13 → 3.13.1 업그레이드와 코드 최적화
- Strings API로 대체 및 StringUtils 불필요 메서드 제거
- CellPhoneValidator 시작 번호 체크 로직 개선
2026-08-18 17:48:58 +09:00
Rinjae 5f5b23026f JSON 및 파일 처리 라이브러리 상향 회귀 테스트 추가
- commons-lang3 3.20.0 업그레이드로 문자열 처리 유지 검증
- 파트 헤더 설정 증가(CVE-2025-48976) 회귀 확인 테스트 추가
- jackson-bom 2.18.10 업그레이드 후 날짜 직렬화 검증
2026-08-18 17:07:21 +09:00
Rinjae 5c61fc3f24 멀티파트 업로드 및 의존성 보안 업데이트
- MultipartConfig: partHeaderSizeMax 설정 추가(CVE-2025-48976 대응)
- build.gradle: commons-fileupload 1.6.0 및 commons-beanutils 1.11.0 업그레이드
2026-08-18 16:56:26 +09:00
Rinjae a7aa5bc98c Spring Boot/Framework/Security 관련 CVE 억제 규칙 추가 및 구성 수정
- dependency-check-suppressions.xml: 오탐 및 비사용 기능 CVE 5건 억제 추가
- PortalConfigSecurity.java: CVE-2026-22732 대응 우회 로직 추가
- build.gradle: Spring 의존성 버전 정리 및 중복 핀 제거
2026-08-18 15:18:08 +09:00
Rinjae bf63701e68 리다이렉트 경로 상수화 및 중복 제거
- "redirect:/", "redirect:/login?reason=auth" 상수로 치환
- 중복된 문자열 정의 제거 및 가독성 개선
2026-08-18 14:50:08 +09:00
Rinjae f26aae296a ECharts 테마 파일 및 ChartEditor.js 제거 - 불필요 리소스 삭제 2026-08-18 14:44:06 +09:00
184 changed files with 7284 additions and 12497 deletions
@@ -0,0 +1,28 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="eapim-portal [clean compileJava]" type="GradleRunConfiguration" factoryName="Gradle" nameIsGenerated="true">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--no-build-cache -x test" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="clean" />
<option value="compileJava" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>
+201 -115
View File
@@ -4,7 +4,7 @@
## 프로젝트 개요
**EAPIM Portal**은 주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
**EAPIM Portal**은 주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
**기술 스택:**
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf
@@ -25,50 +25,52 @@
## Git 브랜치 전략
**저장소 정보:**
- 기본 저장소: `ssh://git@192.168.240.178:18081/eapim/eapim-portal.git`
- 대체 저장소: `https://git.eactive.synology.me:8090/kjb-eapim/eapim-portal.git`
- origin: `ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git` (Gitea, 사내망)
- 저장소가 유일한 remote다. Jenkins 파이프라인도 같은 주소를 본다.
**브랜치:**
- **jenkins_with_weblogic**: Jenkins 빌드와 WebLogic 테스트를 위한 브랜치 (기본 개발 브랜치)
- Jenkins CI/CD 파이프라인 설정 포함
- WebLogic 배포 및 테스트 환경 설정
- 일상적인 개발 작업은 이 브랜치에서 수행
- 기능 개발, 버그 수정, 테스트 등 모든 개발 활동의 기본 브랜치
- **master**: 기본 개발 브랜치이자 배포 기준 브랜치
- 일상적인 개발 작업(기능 개발, 버그 수정)을 여기서 직접 수행한다
- Jenkins 4개 파이프라인(`Jenkinsfile.*`)이 모두 `origin/master` 를 체크아웃한다
- `Jenkinsfile.security` 는 pollSCM 으로 master 변경을 감지해 자동 실행된다
- **master**: 완벽히 동작하는 검증된 코드 저장소 (안정 브랜치)
- 프로덕션 배포 가능한 안정적인 코드만 포함
- jenkins_with_weblogic 브랜치에서 충분히 테스트된 코드만 병합
- 직접 커밋 금지, Pull Request를 통한 병합만 허용
- **feats/\***, **design\***, **develop**: 과거 작업 잔여 브랜치
- 현재 활성 개발에 사용하지 않는다. 참고용으로만 남아 있다
**브랜치 사용 가이드:**
> 과거 문서에 있던 `jenkins_with_weblogic` 브랜치는 **존재하지 않는다**.
> Jenkins/WebLogic 설정은 master 의 `Jenkinsfile.deploy` 에 통합되어 있다.
**작업 흐름:**
```bash
# 개발 시작 시 jenkins_with_weblogic 브랜치에서 작업
git checkout jenkins_with_weblogic
# master 에서 바로 작업
git checkout master
git pull origin master
# 기능 개발 후 커밋
# 커밋 후 푸시 → Jenkins security 파이프라인이 자동 트리거됨
git add .
git commit -m "기능 설명"
# jenkins_with_weblogic 브랜치에 푸시
git push origin jenkins_with_weblogic
# 충분한 테스트 완료 후 master로 병합 (Pull Request 사용)
git push origin master
```
## 빌드 명령어
### 애플리케이션 실행
- gradlew 사용 금지 - offline gradle 단독 실행
- JDK/Gradle 환경 변수는 저장소 루트의 `.envrc`(direnv)로 자동 설정됨 (JAVA_HOME → Zulu JDK 8, GRADLE_USER_HOME → 격리 디렉터리)
- Linux 개발 환경에서는 `gradlew` 대신 설치된 `gradle`(8.7)을 직접 쓴다. Jenkins 노드도 `/apps/opts/gradle-8.7``gradle` 을 쓴다.
- Windows/Eclipse 환경 기동 절차는 `BOOTRUN_SETUP_GUIDE.md` 참고 (거기서는 `gradlew.bat bootRun` 사용).
- `.envrc`(direnv)는 **없다**. JAVA_HOME(JDK 8)/GRADLE_HOME 은 셸에서 직접 맞춰야 한다.
```bash
# dev 프로파일로 실행 (기본값)
# bootRun 기본 프로파일은 build.gradle 의 bootProfile 기본값(local_rinjaemac)
gradle bootRun
# 특정 프로파일로 실행
gradle bootRun --args='--spring.profiles.active=stage'
# 다른 프로파일로 기동 (--args 가 아니라 -PbootProfile)
gradle bootRun -PbootProfile=dev
```
> `bootRun` 의 프로파일은 `-PbootProfile` 로 넘긴다. `-Pprofile` 은 `ext.profile='local'`
> 이 이미 점유하고 있어 무시된다(`build.gradle` 의 주석 참고).
> Jenkins 빌드에서 쓰는 `-Pprofile=weblogic` 은 스프링 프로파일이 아니라 빌드용 플래그다.
### 빌드
```bash
@@ -82,6 +84,9 @@ gradle war
# 클린 후 빌드
gradle clean build
# SBOM(xlsx) 생성 - Jenkins 빌드 파이프라인에서 사용
gradle sbomXlsx
```
### 테스트
@@ -90,7 +95,7 @@ gradle clean build
# 모든 테스트 실행
gradle test
# 커버리지와 함께 테스트 실행
# 로그 상세 출력
gradle test --info
# 특정 테스트 클래스 실행
@@ -100,24 +105,44 @@ gradle test --tests "com.eactive.apim.portal.apps.user.AccountControllerTest"
gradle test --tests "*Controller*"
```
> **커버리지는 측정되지 않는다.** JaCoCo 플러그인이 `build.gradle` 에 없고
> `sonar-project.properties` 에도 `sonar.coverage.jacoco.xmlReportPaths` 가 없다.
> 그래서 SonarQube 의 Coverage 가 항상 0% 로 뜨고 Quality Gate 가 ERROR 가 된다
> (Sonar 의 Zero Coverage Sensor 가 리포트 없는 라인을 전부 0 으로 채운다).
### CSS/SASS 빌드
```bash
npm run sass:build # main.css (expanded)
npm run build # main.css + main.min.css
npm run sass:watch # 변경 감시
./sass-build.sh # sass CLI 직접 호출 (npm 없이)
```
### 개발
```bash
# 현재 프로파일 확인
# 현재 프로파일 확인 (-Pprofile 로 넘긴 값)
gradle printProfile
# 소스 세트 설정 확인
gradle printSourceSets
```
# Docker 이미지 빌드
./build_docker.sh
### Docker
`build_docker.sh`**없다**. `Dockerfile` 만 있으므로 직접 빌드한다.
```bash
gradle bootWar
docker build -t eapim-portal:latest .
```
## 아키텍처
### 멀티 모듈 구조
이 프로젝트는 Gradle composite build를 통해 3개의 외부 모듈에 의존합니다:
이 프로젝트는 `settings.gradle` 의 멀티 프로젝트 구성으로 2개의 형제 디렉터리 모듈에 의존합니다:
1. **elink-online-core-jpa** (`../eapim-online/elink-online-core-jpa`)
- Gateway 데이터 모델 및 JPA 엔티티
@@ -129,37 +154,53 @@ gradle printSourceSets
- 기본 리포지토리 구현, QueryDSL 지원
- 공통 예외 핸들러 및 보안 유틸리티
3. **kjb-safedb** (`../kjb-safedb`)
- 광주은행 SafeDB 암호화 라이브러리
- 데이터베이스 레벨 민감 데이터 암호화
- 암호화 컬럼(사용자 비밀번호, API 키 등)에 필수
두 모듈은 Jenkins 파이프라인의 `Checkout dependencies` 스테이지가 같은 Gitea 서버에서
자동으로 clone/reset 한다. 로컬에서도 `../eapim-online/elink-online-core-jpa`
`../elink-portal-common` 이 없으면 컴파일 자체가 되지 않는다.
**kjb-safedb (SafeDB 암호화 라이브러리)는 현재 빌드에서 빠져 있다.**
`build.gradle``implementation project(':kjb-safedb')` 가 주석 처리되어 있고
`settings.gradle` 에도 등록되어 있지 않으며 `../kjb-safedb` 디렉터리도 없다.
다시 붙일 때는 세 곳을 모두 되살려야 한다.
### 패키지 구조
코드는 기술 계층이 아닌 **기능 모듈**(수직 분할) 방식으로 구성됩니다:
```
com.eactive.apim.portal/
├── apps/ # 기능 모듈
│ ├── agreements/ # API 약관
│ ├── apis/ # API 카탈로그 & 문서
│ ├── apiservice/ # API 서비스 그룹핑
│ ├── app/ # API 키 관리
│ ├── approval/ # 승인 워크플로우
│ ├── auth/ # 인증
│ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의
│ ├── dashboard/ # 통계 & 분석
│ ├── file/ # 파일 업로드/다운로드
│ ├── login/ # 로그인/로그아웃
│ ├── proxy/ # API 테스트용 Forward Proxy
│ ├── sample/ # 샘플 코드 생성
└── user/ # 사용자 관리
├── common/ # 공통 관심사
├── config/ # Spring 설정
└── gateway/ # Gateway DB 직접 접근
── data/ # Gateway 리포지토리
com.eactive.apim/
├── portal/
│ ├── PortalApplication.java
│ ├── apps/ # 기능 모듈
│ ├── HealthCheckController.java
│ ├── ReadinessController.java
│ ├── agreements/ # API 약관
│ ├── apis/ # API 카탈로그 & 문서
│ ├── apiservice/ # API 서비스 그룹핑
│ ├── app/ # API 키 관리
│ ├── approval/ # 승인 워크플로우
│ ├── auth/ # 인증
│ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의
│ ├── dashboard/ # 대시보드
│ ├── file/ # 파일 업로드/다운로드
│ │ ├── login/ # 로그인/로그아웃
│ │ ├── main/ # 메인 화면
│ │ ├── sample/ # 샘플 코드 생성
│ │ ── session/ # 세션 관리 (filter/entity/repository 포함)
│ │ ├── statistics/ # API 통계
│ │ └── user/ # 사용자 관리
│ ├── common/ # 공통 관심사
│ ├── config/ # Spring 설정
│ ├── custom/ # 사이트별 커스터마이징 설정
│ ├── djb/ # 은행 특화 기능
│ │ ├── apistatus/ community/ footer/ guide/ menu/ notitest/
│ ├── spring/ # DatabaseSessionVerifier 등
│ └── tools/ # HibernateSqlGenerator, JpaErrorLoggingAspect
└── gateway/ # Gateway DB 직접 접근 (portal 의 하위가 아님)
```
> `apps/proxy/` 패키지는 **더 이상 없다**. 과거 문서의 Forward Proxy 모듈 설명은 무효다.
각 기능 모듈은 일반적으로 다음을 포함합니다:
- `controller/` - Spring MVC 컨트롤러 (@Controller)
- `service/` - 비즈니스 로직 (@Service, @Transactional)
@@ -183,6 +224,9 @@ com.eactive.apim.portal/
- Entities: `com.eactive.eai.data.entity.onl.*`
- 목적: API 명세, 서비스, 메시지
두 데이터소스 모두 **dev 프로파일에서도 JNDI 로 연결한다**(`application-dev.yml`, `application-stage.yml`).
직접 JDBC URL 을 쓰는 프로파일은 없다.
설정: `config/PortalDatasourceConfiguration.java`
- 각 데이터베이스별 별도 EntityManager
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
@@ -193,18 +237,25 @@ com.eactive.apim.portal/
환경별 설정 파일: `src/main/resources/application-{profile}.yml`
- **dev**: 개발 환경 (Oracle at 192.168.240.177:1599, DevTools 활성화, SQL 로깅)
저장소에 실제로 있는 프로파일은 4개다:
- **dev**: 개발 환경 (DevTools 활성화, SQL 로깅)
- **stage**: 스테이징 환경 (JNDI 데이터소스, proxy to inter-dapiwas01)
- **prod**: 운영 환경 (JNDI 데이터소스, proxy to inter-apiwas00, 캐싱 활성화)
- **gf63**: 개인 개발 환경
- **kjb_rinjae**: 개인 개발 환경
- **local_gf63**: 개인 로컬 개발 환경
주요 설정:
- 세션 타임아웃: 15분
- 파일 업로드 최대: 8MB
- 비밀번호 만료: 90일
- 인증 토큰 TTL: 5분 (300초)
- 사용자 승인 필수: true
> `bootRun` 의 기본값은 `build.gradle` 에서 `local_rinjaemac` 으로 잡혀 있지만
> `application-local_rinjaemac.yml` 은 저장소에 없다. 기본값 그대로 `gradle bootRun` 을
> 하면 해당 프로파일 설정 없이 뜨므로, 로컬 기동 시에는 `-PbootProfile=dev` 처럼 명시하는 편이 안전하다.
주요 설정 (`application.yml`):
- 세션 타임아웃: **10분** (`server.servlet.session.timeout: 10m`, WebLogic 은 `weblogic.xml``timeout-secs 600`)
- 세션 쿠키명: `JSESSIONID_PORTAL`
- 파일 업로드 최대: **10MB** (`portal.file.max-size`)
- 비밀번호 만료: 90일 (`portal.password-expiration-days`)
- 인증 토큰 TTL: 5분 (`portal.auth-ttl: 300`), 재발송 제한 30초
- 사용자 승인 필수: `portal.user-approval: true`
- 내부 사용자 판별 도메인: `portal.internal-user.email-domains`
## 주요 기능 및 비즈니스 로직
@@ -237,12 +288,14 @@ API 키는 승인 워크플로우와 함께 관리됩니다:
엔티티: `Approval`, `Approver`, `ApprovalStatus` (WAITING, APPROVED, REJECTED)
### API 테스트 (`apps/proxy/`, `apps/sample/`)
### API 테스트 (`apps/apis/`)
- Gateway 서버로의 Forward Proxy
- 요청/응답 로깅이 있는 API 테스터
- 여러 언어의 샘플 코드 생성
- API 문서를 위한 Swagger UI 통합
- API 테스터: `apps/apis/filter/ApiTesterFilter.java`
- Testbed 스펙 제공: `apps/apis/controller/TestbedSpecController.java`
- 샘플 코드 템플릿: `application.yml``sample-code-path: classpath:/templates/sample_code`
> `apps/sample/` 은 샘플 코드 생성기가 아니라 **Thymeleaf/Security 데모 컨트롤러**
> (`ThymeleafDemoController`, `SecurityThymeleafDemoController` 등) 모음이다.
### 감사 추적 (Hibernate Envers)
@@ -321,16 +374,22 @@ public interface UserMapper {
보안 설정: `config/PortalConfigSecurity.java`
- 커스텀 인증 관리자: `PortalAuthenticationManager`
- `changeSessionId()`를 통한 세션 고정 공격 방어
- 쿠키 기반 토큰을 사용한 CSRF 방어
- Lucy 필터를 통한 XSS 방어
- CSRF: **`HttpSessionCsrfTokenRepository`(세션 저장)**, 헤더명 `X-XSRF-TOKEN` 고정
— 쿠키 기반(`CookieCsrfTokenRepository`)이 아니다. 토큰 수명은 세션 타임아웃과 같다
- XSS: Naver Lucy `XssEscapeServletFilter`
- 비밀번호 전송암호화(RSA-OAEP + AES-GCM): `common/security/passwordcrypto/`
— 필터 순서상 MultipartFilter 이후, Lucy XSS 필터 이전에 복호화된다
역할 계층:
역할 계층 (정의: `src/main/resources/roles.yml`, 부팅 시 `PTL_ROLE` / `PTL_ROLE_AUTHORITY` 에 미러 적재):
```
ROLE_USER → [ROLE_INQUIRY, ROLE_ACCOUNT]
ROLE_CORP_USER → [ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT]
ROLE_CORP_MANAGER → [ROLE_API_KEY_REQUEST, ROLE_INQUIRY, ROLE_APP,
ROLE_ACCOUNT, ROLE_CORP_API, ROLE_DASHBOARD,
ROLE_USER_MANAGER]
ROLE_USER (개인사용자) → [ROLE_INQUIRY, ROLE_ACCOUNT]
ROLE_CORP_USER (법인사용자) → [ROLE_API_KEY_REQUEST, ROLE_API_KEY_REQUEST_VIEW,
ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT]
ROLE_CORP_MANAGER (법인관리자) → [ROLE_API_KEY_REQUEST, ROLE_API_KEY_REQUEST_VIEW,
ROLE_WEBHOOK, ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT,
ROLE_CORP_API, ROLE_DASHBOARD, ROLE_USER_MANAGER]
```
### 데이터베이스 쿼리
@@ -377,15 +436,16 @@ return queryFactory.selectFrom(user)
### 로깅
`application.yml`의 로깅 설정:
```yaml
logging:
file:
path: /Log/App/eapim/
level:
com.eactive.apim.portal: DEBUG
org.hibernate.SQL: DEBUG (개발 환경만)
```
로깅은 `application.yml` `logging.file.path` 가 아니라 **logback 설정으로 제어**한다.
- 설정 파일: `src/main/resources/logback-spring.xml` (그 외 `logback-debug.xml`, `logback-local_gf63.xml`)
- 로그 디렉터리: `portal.logging.log-path` yml 프로퍼티 + 인스턴스명
```xml
<springProperty scope="context" name="profileLogPath" source="portal.logging.log-path"/>
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
```
- `application.yml` 기본값: `portal.logging.log-path: /logs/prod/eapim`
- 산출 파일: `${LOG_PATH}/portal.log`, `${LOG_PATH}/hibernate.log` 등 (`backup/` 에 일자 롤링)
Lombok의 `@Slf4j`와 함께 SLF4J 사용:
```java
@@ -434,12 +494,10 @@ class UserControllerTest extends BaseWebTest {
}
```
### SafeDB 테스트
### SafeDB
테스트에서는 기본적으로 SafeDB 암호화가 비활성화됩니다. 필요한 경우:
```bash
gradle test -Dsafedb=/path/to/safedb
```
SafeDB 모듈이 현재 빌드에서 빠져 있으므로(위 "멀티 모듈 구조" 참고) 테스트에서
별도로 켜고 끌 것이 없다. `-Dsafedb=` 옵션은 더 이상 동작하지 않는다.
## 중요 사항
@@ -451,15 +509,6 @@ gradle test -Dsafedb=/path/to/safedb
- 한글 메시지 프로퍼티 (`messages_ko.properties`)
- 한글 사용자 가이드 (`개발자포탈.md`)
### Forward Proxy 설정
API 테스트는 Gateway로의 Forward Proxy 사용:
- Dev: `http://localhost:10000`
- Stage: `http://inter-dapiwas01:10000`
- Prod: `http://inter-apiwas00:10000`
설정: `apps/proxy/service/ProxyService.java`
### 비밀번호 정책
`PortalUserValidator`에서 적용:
@@ -472,29 +521,63 @@ API 테스트는 Gateway로의 Forward Proxy 사용:
### 세션 관리
- 사용자당 단일 세션 강제
- 세션 타임아웃: 15분
- 로그인 시 세션 고정 공격 방어
- Redis/Ehcache를 통한 세션 클러스터링 (스테이징/운영)
- 세션 타임아웃: **10분** (쿠키명 `JSESSIONID_PORTAL`)
- 로그인 시 `changeSessionId()` 로 세션 고정 공격 방어
- 세션 상태는 DB 에 보관하며 `apps/session/` (filter/entity/repository) 과
`spring/DatabaseSessionVerifier.java` 가 담당한다. **Redis 는 쓰지 않는다.**
### 파일 업로드 제한
- 최대 파일 크기: 8MB
- `FileService`에서 허용 확장자 설정
- 파일 저장 위치: `/Log/App/eapim/files/`
- 업로드 시 XSS 스캐닝
`application.yml` 의 `portal.file` 로 제어하고 `config/PortalProperties.java` 가 바인딩한다:
```yaml
portal:
file:
max-size: 10MB
allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png
```
- 적용 지점: `config/MultipartConfig.java` (`setMaxUploadSize` / `setMaxUploadSizePerFile`)
- 초과 시 메시지: `common/exception/PortalGlobalExceptionHandler.java`
- `PortalProperties` 의 코드 기본값은 `8MB` 이지만 yml 이 `10MB` 로 덮어쓴다
- 다운로드는 `apps/file/controller/FileDownloadController.java` 하나뿐이다
## 배포
### 애플리케이션 서버 지원
WAR 파일 호환 서버:
- **JEUS** (`jeus-web-dd.xml` 필요)
- **WebLogic** (`weblogic.xml` 필요)
- **WebLogic** (`src/main/resources/weblogic.xml`) — 현재 실제 배포 대상
- **JEUS** (`src/main/resources/jeus-web-dd.xml`)
- **Tomcat** (Spring Boot 내장)
실제 배포는 `Jenkinsfile.deploy` 가 수행한다: `djb-vm` 노드에서 WAR 를 빌드해 stash 하고,
`weblogic` 라벨 노드에서 WebLogic 정지 → WAR 교체 → 기동 → readiness 확인 순으로 진행한다.
### Jenkins 파이프라인
저장소 루트에 4개의 Jenkinsfile 이 있고 모두 `origin/master` 를 대상으로 한다:
| 파일 | 용도 |
|---|---|
| `Jenkinsfile.security` | OWASP Dependency-Check(SCA) + SonarQube(SAST). pollSCM 자동 트리거 |
| `Jenkinsfile.sonar` | SonarQube 정적 분석 전용 |
| `Jenkinsfile.test-build` | 테스트 + WAR 빌드 + SBOM |
| `Jenkinsfile.deploy` | 빌드 후 WebLogic 배포 |
빌드는 JDK 8(`/apps/opts/jdk8`), SonarScanner/Dependency-Check 실행은 JDK 17
(`/apps/opts/jdk17`)로 분리되어 있다. 스캐너 설정은 `sonar-project.properties` 와
`ci/` 디렉터리(`sonar-classpath.gradle`, `dependency-check-classpath.gradle`,
`dependency-check-suppressions.xml`)에 있다.
알려진 CI 이슈 두 가지:
- **Coverage 0% → Quality Gate ERROR**: JaCoCo 미설정 (위 "테스트" 섹션 참고)
- **Dependency-Check NVD 갱신 실패**: Jenkins 에 `nvd-api-key` credential 이 없다.
폐쇄망/키 부재 시에는 `UPDATE_NVD=false` 로 실행해 캐시 DB 로만 검사한다.
### JNDI 설정 필수
스테이징/운영 환경에서 JNDI 리소스 설정:
dev 를 포함한 모든 환경에서 WAS 에 JNDI 리소스가 있어야 한다:
- `jdbc/dsOBP_EMS` → Portal 데이터베이스
- `jdbc/dsOBP_AGW` → Gateway 데이터베이스
@@ -511,8 +594,8 @@ JAVA_OPTS="-Xmx2g -Xms1g -Dspring.profiles.active=prod"
# WAR 빌드
gradle bootWar
# Docker 이미지 빌드
./build_docker.sh
# Docker 이미지 빌드 (build_docker.sh 는 없다)
docker build -t eapim-portal:latest .
# 컨테이너 실행
docker run -p 30200:30200 \
@@ -526,13 +609,16 @@ docker run -p 30200:30200 \
다음 항목에 영향을 주는 변경 시:
- **Gateway API 명세**: `elink-online-core-jpa` 모듈 확인
- **공통 유틸리티**: `elink-portal-common` 모듈 확인
- **암호화**: `kjb-safedb` 모듈 확인
- **암호화**: `kjb-safedb` 모듈 — 현재 빌드에서 제외됨 (위 "멀티 모듈 구조" 참고)
- **Admin 포털**: `../eapim-admin/` 관련 프로젝트
- **Online 포털**: `../eapim-online/` 관련 프로젝트
## 추가 자료
- 사용자 가이드 (한글): `개발자포탈.md`
- 빌드 스크립트: `build-gf63.sh`, `deploy_portal.sh`, `deploy_portal2.sh`
- Docker: `Dockerfile`, `build_docker.sh`
- SQL 스크립트: `../kjb-eapim-sql/`
- 로컬 기동 가이드 (Windows/Eclipse): `BOOTRUN_SETUP_GUIDE.md`
- 개발환경 준비, OHS 정적리소스 설정: `djb-docs/`
- 메뉴 관리 개발 가이드: `readme-docs/메뉴-관리-개발-가이드.md`
- SASS/프론트 빌드: `package.json`, `sass-build.sh`, `tools/forge-entry.js`
- Docker: `Dockerfile`
- CI 설정: `Jenkinsfile.*`, `sonar-project.properties`, `ci/`
+2 -1
View File
@@ -74,8 +74,9 @@ pipeline {
set -eu
cd build/libs
sha256sum eapim-portal.war > eapim-portal.war.sha256
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
stash name: 'war', includes: 'build/libs/eapim-portal.war'
}
}
+19 -3
View File
@@ -43,8 +43,9 @@ pipeline {
)
booleanParam(
name: 'RUN_TESTS',
defaultValue: false,
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
defaultValue: true,
description: '단위 테스트 + JaCoCo 커버리지를 Sonar 로 전송한다(분석 시간 증가). ' +
'끄면 커버리지 리포트가 없어 Sonar 가 Coverage 를 0% 로 집계하고 Quality Gate 가 실패한다.'
)
booleanParam(
name: 'UPDATE_NVD',
@@ -166,14 +167,18 @@ pipeline {
}
}
// test 는 finalizedBy jacocoTestReport 로 커버리지 XML 까지 만든다(build.gradle).
// 이 스테이지를 건너뛰면 Sonar 가 커버리지 리포트를 못 받아 Coverage 0% 로 집계되고
// Quality Gate 의 "새 코드 커버리지" 조건에서 반드시 실패한다.
stage('Test') {
when { expression { return params.RUN_TESTS } }
steps {
sh 'gradle test --no-daemon'
sh 'gradle test jacocoTestReport --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts artifacts: 'build/reports/jacoco/test/**', allowEmptyArchive: true, fingerprint: false
}
}
}
@@ -419,6 +424,16 @@ pipeline {
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
fi
# 커버리지. sonar.junit.reportPaths 는 테스트 성공/실패 개수일 뿐
# 커버리지가 아니다. JaCoCo XML 을 따로 넘겨야 Coverage 지표가 채워진다.
COVERAGE_ARG=""
JACOCO_XML=build/reports/jacoco/test/jacocoTestReport.xml
if [ -f "$JACOCO_XML" ]; then
COVERAGE_ARG="-Dsonar.coverage.jacoco.xmlReportPaths=$JACOCO_XML"
else
echo "WARN: $JACOCO_XML 없음 - Coverage 는 0% 로 집계된다(RUN_TESTS 를 켰는지 확인)."
fi
# SonarQube 에 Dependency-Check 플러그인이 설치돼 있으면 CVE 결과도 함께 올린다.
# 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만).
DC_ARG=""
@@ -445,6 +460,7 @@ pipeline {
-Dsonar.java.libraries="$LIBS" \
-Dsonar.java.test.libraries="$TEST_LIBS" \
$JUNIT_ARG \
$COVERAGE_ARG \
$DC_ARG
'''
}
+17 -4
View File
@@ -26,8 +26,9 @@ pipeline {
parameters {
booleanParam(
name: 'RUN_TESTS',
defaultValue: false,
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
defaultValue: true,
description: '단위 테스트 + JaCoCo 커버리지를 Sonar 로 전송한다(분석 시간 증가). ' +
'끄면 커버리지 리포트가 없어 Sonar 가 Coverage 를 0% 로 집계하고 Quality Gate 가 실패한다.'
)
}
@@ -121,14 +122,16 @@ pipeline {
}
}
// test 는 finalizedBy jacocoTestReport 로 커버리지 XML 까지 만든다(build.gradle).
stage('Test') {
when { expression { return params.RUN_TESTS } }
steps {
sh 'gradle test --no-daemon'
sh 'gradle test jacocoTestReport --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts artifacts: 'build/reports/jacoco/test/**', allowEmptyArchive: true, fingerprint: false
}
}
}
@@ -199,6 +202,15 @@ pipeline {
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
fi
# 커버리지. JUNIT_ARG 는 테스트 성공/실패 개수일 뿐 커버리지가 아니다.
COVERAGE_ARG=""
JACOCO_XML=build/reports/jacoco/test/jacocoTestReport.xml
if [ -f "$JACOCO_XML" ]; then
COVERAGE_ARG="-Dsonar.coverage.jacoco.xmlReportPaths=$JACOCO_XML"
else
echo "WARN: $JACOCO_XML 없음 - Coverage 는 0% 로 집계된다(RUN_TESTS 를 켰는지 확인)."
fi
# Tools 에 등록된 스캐너가 없으면 PATH 에서 찾는다
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
@@ -215,7 +227,8 @@ pipeline {
-Dsonar.projectVersion="${BUILD_NUMBER}" \
-Dsonar.java.libraries="$LIBS" \
-Dsonar.java.test.libraries="$TEST_LIBS" \
$JUNIT_ARG
$JUNIT_ARG \
$COVERAGE_ARG
'''
}
}
+2 -1
View File
@@ -96,8 +96,9 @@ pipeline {
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
}
}
}
+119 -11
View File
@@ -3,6 +3,7 @@ plugins {
id 'war'
id 'eclipse'
id 'idea'
id 'jacoco'
id 'org.cyclonedx.bom' version '3.2.4'
id 'org.springframework.boot' version '2.7.18'
id 'io.spring.dependency-management' version '1.1.3'
@@ -83,7 +84,14 @@ dependencies {
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
// Thymeleaf 3.1 코어가 #temporals 를 내장 제공 → java8time extras 를 함께 두면 표현식 객체가 중복 등록된다.
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
}
// WW-5417 관련 public 필드 접근 권한 검사 누락 수정(OGNL #264/#265).
// 3.3.x EOL 계열의 단기 조치. 3.4.x는 Thymeleaf 3.1.5의 OgnlContext 생성자와 비호환.
// 일반 OGNL 경로도 ThymeleafExpressionCompatibilityTest로 검증한다(Spring EL만으로는 확인 불가).
implementation 'ognl:ognl:3.3.5'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation('org.springframework.boot:spring-boot-starter-cache')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
@@ -91,33 +99,48 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'org.springframework:spring-expression:5.3.30'
// spring-expression 개별 pin 제거: 아래 ext 의 spring-framework.version 이 전 모듈을 일괄 관리한다.
// 개별 pin 이 남아 있으면 다른 spring-* 모듈보다 낮은 버전으로 고정되어 버전이 어긋난다.
implementation group: 'xalan', name: 'xalan', version: '2.7.3'
implementation 'org.hibernate:hibernate-envers:5.6.15.Final'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.0.0'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.4.0' // 3.0.0 은 thymeleaf 3.0 전용
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
implementation 'com.github.ua-parser:uap-java:1.5.3'
// uap-java 제거: 소스 전체에 ua_parser 참조 0건이고, 1.5.3 은 snakeyaml 2.x 에서 삭제된
// SafeConstructor() no-arg 를 호출해 NoSuchMethodError 를 낸다. 되살릴 경우 1.6.1 이상.
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1'
implementation 'javax.servlet:javax.servlet-api:4.0.0'
implementation 'org.jasypt:jasypt:1.9.3'
implementation 'xerces:xercesImpl:2.12.2'
implementation 'org.apache.commons:commons-lang3:3.12.0'
// weblogic.xml prefer-application-packages 가 com.fasterxml.jackson.* 를 앱 우선으로 강제한다
// (oracle_common jackson-databind 2.17.0 vs 앱 2.18.10 split-package 방지, WLog 실사용 확인).
// Thymeleaf StandardJavaScriptSerializer 가 ObjectMapper.findAndRegisterModules() 로
// jackson-module-jaxb-annotations 를 옵션 모듈로 자동탐색하는데, 이 모듈은 WebLogic 번들에만
// 있고 앱엔 없어 ServiceConfigurationError(Provider not found) 발생 → 앱에도 명시 추가해
// jackson 전체를 자체 완결시킨다(jackson-bom 2.18.10 로 버전 통일).
implementation 'com.fasterxml.jackson.module:jackson-module-jaxb-annotations'
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
implementation 'org.apache.commons:commons-lang3:3.20.0'
implementation 'org.apache.commons:commons-collections4:4.4'
implementation 'commons-net:commons-net:3.9.0'
implementation('commons-beanutils:commons-beanutils:1.9.4') {
// CVE-2025-48734 (PropertyUtilsBean 이 enum 의 declaredClass 프로퍼티 노출 → ClassLoader 접근/RCE).
// 1.11.0 부터 SuppressPropertiesBeanIntrospector 가 기본 활성이라 declaredClass 접근이 차단된다.
// 이 앱의 호출부(PasswordMatchValidator / PasswordRuleValidator / AuthNumberValidator)는
// 어노테이션에 박힌 고정 프로퍼티명만 넘기므로 외부 입력 경로는 없지만 버전은 올려 둔다.
// 1.11.0 = Java 8 바이트코드(major 52), PropertyUtils.getProperty/getNestedProperty API 동일.
implementation('commons-beanutils:commons-beanutils:1.11.0') {
// exclude group: 'commons-collections', module: 'commons-collections'
}
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
// WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
// jackson 개별 pin 제거: 아래 ext 의 jackson-bom.version 이 전 모듈을 일괄 관리한다.
// 개별 pin 은 BOM 보다 우선하므로 남겨 두면 BOM 만 올렸을 때 core/annotations/databind 가
// 옛 버전에 고정돼 버전이 어긋난다(실제로 그런 상태였다).
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
@@ -127,7 +150,10 @@ dependencies {
implementation 'net.bytebuddy:byte-buddy:1.14.5'
// Commons FileUpload (WAS 독립적인 multipart 처리)
implementation 'commons-fileupload:commons-fileupload:1.5'
// CVE-2025-48976 (멀티파트 파트 헤더 크기 제한 부재 → DoS). 1.6.0 에서 partHeaderSizeMax 도입.
// 주의: 1.6 부터 파트 헤더 총량 기본 상한이 10240 → 512 바이트로 줄었다(DEFAULT_PART_HEADER_SIZE_MAX).
// 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 길어질 수 있어 실측으로 여유를 확인했다.
implementation 'commons-fileupload:commons-fileupload:1.6.0'
implementation 'commons-io:commons-io:2.15.1'
@@ -145,6 +171,49 @@ ext {
springMavenArtifactVersion = '5.3.30'
encoding = 'UTF-8'
profile = 'local'
// 내장 Tomcat 버전 상향 (Spring Boot 2.7.18 기본값 9.0.83 → 9.0.120).
// Boot BOM 의 tomcat.version 프로퍼티를 덮어써서 tomcat-embed-core/-el/-websocket 이 함께 올라간다.
// 9.0.x 계열 유지 = Servlet 4.0 / javax.* 네임스페이스 그대로, JDK8 호환.
set('tomcat.version', '9.0.120')
// CVE-2022-1471 (snakeyaml Constructor 임의 타입 역직렬화 → RCE). Boot 2.7.18 BOM 기본값 1.30 → 2.6.
// 앱/프레임워크 실경로는 이미 SafeConstructor 계열(OriginTrackedYamlLoader, spring-beans
// FilteringConstructor, swagger-parser DeserializationUtils)이라 익스플로잇 경로는 없었으나
// SCA 는 버전으로 판정하므로 2.x 로 올린다. snakeyaml 2.6 = Java 8 바이트코드(major 52).
// 2.6 인 이유: swagger-core 2.2.52 / swagger-parser-v3 2.1.45 가 요구하는 버전이라 강등이 없다.
// 부수 효과: swagger-parser 가 호출하는 LoaderOptions.setCodePointLimit(1.32+ API) 도 해소.
set('snakeyaml.version', '2.6')
// jackson 전 모듈 버전 통일(Boot 2.7.18 BOM 기본 2.13.5). 2.18.x 는 JDK8 호환 라인이다.
// 이유 3가지
// 1) snakeyaml 2.x 는 ParserImpl(StreamReader) 를 제거했고 jackson-dataformat-yaml 은
// 2.15+ 부터 ParserImpl(StreamReader, LoaderOptions) 를 쓴다 — 위 snakeyaml 상향의 전제.
// 2) WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정.
// 3) jackson-databind PolymorphicTypeValidator 우회(제네릭 타입 인자 미검증) — 2.18.8 에서 수정.
// 이 앱은 다형성 역직렬화(activateDefaultTyping/@JsonTypeInfo)를 쓰지 않아 노출 경로는 없다.
// 2.18.x 마지막 패치를 쓴다.
set('jackson-bom.version', '2.18.10')
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
set('thymeleaf.version', '3.1.5.RELEASE')
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
// 5.3.32~5.3.39 구간 CVE 정리용. 5.3.x 는 OSS EOL 이라 이 위로는 상용(Enterprise) 빌드뿐이다.
// 남는 5.3.x 지적(CVE-2026-41855 JMS Jackson 역직렬화, CVE-2016-1000027 HttpInvoker)은
// 5.3 계열에 수정본이 없고 앱이 JMS/HttpInvoker 를 쓰지 않으므로 억제 항목으로 따로 관리한다.
set('spring-framework.version', '5.3.39')
// Spring Security 5.7.x OSS 마지막 릴리스(Boot 2.7.18 BOM 기본 5.7.11).
// CVE-2026-22732(응답 커밋 후 보안 헤더 미기록)는 수정본이 5.7.22/5.8.24 = Enterprise 전용이라
// OSS 로는 올릴 수 없다. 우회책(HeaderWriterFilter.shouldWriteHeadersEagerly=true)은 동작 변경이라 분리 검토.
set('spring-security.version', '5.7.14')
}
@@ -222,6 +291,33 @@ test {
}
}
enabled = true
// 테스트가 끝나면 커버리지 리포트를 자동 생성한다.
// SonarQube 는 리포트가 없으면 "미측정"이 아니라 0% 로 집계하므로(Zero Coverage Sensor)
// 리포트 생성이 빠지면 Quality Gate 의 "새 코드 커버리지" 조건에서 항상 실패한다.
finalizedBy jacocoTestReport
}
// JaCoCo 0.8.11 = Gradle 8.7 기본값. JDK 8 바이트코드 계측 지원.
jacoco {
toolVersion = '0.8.11'
}
jacocoTestReport {
dependsOn test
reports {
// Sonar 가 읽는 형식은 XML 뿐이다. HTML 은 사람이 볼 용도.
xml.required = true
html.required = true
csv.required = false
}
// 여기서 클래스를 제외하지 말 것.
// JaCoCo 리포트에서 빠진 클래스는 Sonar 에 "데이터 없음"으로 도달하고,
// Sonar 의 Zero Coverage Sensor 가 그 파일을 0% 로 채워 넣는다(= 오히려 악화).
// 커버리지 집계에서 빼고 싶은 파일은 sonar-project.properties 의
// sonar.coverage.exclusions 로 지정한다.
}
// 로컬 전용 설정 파일. 배포 산출물(WAR)에 실리면 Actuator/SBA 설정이 그대로 노출되어
@@ -251,6 +347,18 @@ war {
classpath = excludeLocalOnlyLibs(classpath)
}
// 정적 리소스만 별도 zip (OHS 추가 배포용). war/bootWar 는 그대로 정적 리소스 포함 유지
// (WAS 단독 접속 인원 지원). sass/ 는 컴파일 소스이며 실제 서빙 경로에 없어 제외
// (PortalConfigWebDispatcherServlet#addResourceHandlers 기준).
task staticResourcesZip(type: Zip) {
archiveFileName = "eapim-portal-static.zip"
destinationDirectory = file("$buildDir/libs")
from('src/main/resources/static') {
exclude 'sass/**'
}
}
assemble.dependsOn staticResourcesZip
task printSourceSets {
doLast {
sourceSets.each { srcSet ->
+215
View File
@@ -28,4 +28,219 @@
</suppress>
-->
<!-- ================================================================================
Spring 계열 Critical 5건 (2026-08-18 리포트 기준).
공통 배경: Spring Framework 5.3.x / Spring Boot 2.7.x / Spring Security 5.7.x 는 모두 OSS EOL 이라
남은 수정본이 상용(Enterprise/Tanzu)뿐이다. JDK 8 유지 제약상 Boot 3 + Spring 6 이관 전까지는
버전 상향으로 못 없앤다. 아래는 "코드에 트리거 경로가 없음"을 근거로 한 억제이며 만료일을 둔다.
버전 범위를 5.3.x / 2.7.x / 5.7.x 로 고정해 두었으므로, 이관 후에는 억제가 자동으로 풀린다.
재검토 시 확인할 것: (1) 각 근거 grep 이 여전히 0건인지 (2) OSS 수정본이 나왔는지.
================================================================================ -->
<!-- 1. Spring Boot: Cassandra SSL 호스트명 미검증 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-40974 는 Spring Boot 의 Cassandra SSL 자동설정이 SSL 번들의 호스트명 검증 설정을
드라이버에 전달하지 않는 문제다. 이 앱은 Cassandra 를 쓰지 않는다
(runtimeClasspath 에 cassandra/datastax 계열 jar 0건, spring-boot-starter-data-cassandra 미선언).
수정본 2.7.33 은 상용(Enterprise) 릴리스라 OSS 로는 올릴 수 없다.
devtools/actuator/spring-boot-admin jar 는 스캔 대상(runtimeClasspath)에는 있으나
배포 산출물에서는 build.gradle 의 localOnlyLibPrefixes 로 제외되어 WAR 에 실리지 않는다.
확인: Rinjae / 2026-08-18 / grep -ri cassandra 0건, WAR 내 devtools/actuator jar 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
<cve>CVE-2026-40974</cve>
</suppress>
<!-- 2. Thymeleaf SSTI 2건: starter jar 이름 기준 오탐 -->
<suppress>
<notes><![CDATA[
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
thymeleaf.version=3.1.5.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.5.RELEASE.jar 다.
Dependency-Check 가 spring-boot-starter-thymeleaf-2.7.18.jar(의존만 선언한 빈 starter)에
thymeleaf CPE 를 잘못 매칭한 결과다. thymeleaf 본체 jar 에 대한 탐지는 억제하지 않는다.
확인: Rinjae / 2026-08-18 / WAR 내 thymeleaf 본체 jar 가 수정본임을 확인.
재확인: Rinjae / 2026-09-15 / war classpath 실측 — thymeleaf-3.1.5.RELEASE.jar,
thymeleaf-spring5-3.1.5.RELEASE.jar, thymeleaf-extras-springsecurity5-3.1.5.RELEASE.jar.
(2026-08-18 주석의 3.1.4 표기는 이후 3.1.5 상향분이 반영되지 않은 것이라 함께 갱신함.)
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$</packageUrl>
<cve>CVE-2026-40477</cve>
<cve>CVE-2026-40478</cve>
</suppress>
<!-- 3. Spring Framework: JMS Jackson 역직렬화 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-41855 는 org.springframework.jms.support.converter.MappingJackson2MessageConverter /
JacksonJsonMessageConverter 가 임의 클래스 인스턴스화를 허용하는 문제다(신뢰할 수 없는 JMS 환경 전제).
이 앱은 JMS 를 쓰지 않는다: runtimeClasspath 에 spring-jms 0건, 소스에 javax.jms /
JmsTemplate / MappingJackson2MessageConverter 참조 0건.
5.3.x 는 OSS 수정본이 없다(6.2.19 / 7.0.8 에서만 수정). 이미 5.3.x OSS 마지막인 5.3.39 로 올려 둔 상태다.
확인: Rinjae / 2026-08-18 / grep -rn "javax.jms|JmsTemplate|MappingJackson2MessageConverter" src 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-.*@5\.3\..*$</packageUrl>
<cve>CVE-2026-41855</cve>
</suppress>
<!-- 4. spring-web: HttpInvoker 역직렬화 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2016-1000027 은 HttpInvokerServiceExporter 를 노출했을 때만 성립한다. 해당 클래스는
Spring 6.0 에서 제거됐고 5.3.x 에는 수정본이 없다(= 5.3.x 를 쓰는 한 계속 탐지된다).
이 앱은 HttpInvoker 계열을 쓰지 않는다: 소스에 HttpInvoker 참조 0건이고 외부 호출은
RestTemplate / HttpClient 기반이다.
근본 해결은 Spring 6(Boot 3, JDK 17) 이관. 이관 전까지 억제한다.
확인: Rinjae / 2026-08-18 / grep -rn HttpInvoker src 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-web@5\.3\..*$</packageUrl>
<cve>CVE-2016-1000027</cve>
</suppress>
<!-- 5. Spring Security: 응답 커밋 시 보안 헤더 미기록 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-22732 는 응답이 커밋된 뒤 Spring Security 가 보안 헤더를 기록하지 못하는 문제다.
트리거는 Content-Length 를 setHeader / setIntHeader / addIntHeader 로 지정하는 경로다
(OnCommittedResponseWrapper 는 setContentLength / setContentLengthLong / addHeader 만 추적한다).
(1) 이 코드베이스에는 트리거가 없다: portal / elink-portal-common / elink-online-core-jpa 전체에
setIntHeader, addIntHeader, setHeader("Content-Length") 0건. 파일 다운로드는
response.setContentLength(int) 를 쓰며 실측상 보안 헤더가 정상 기록된다.
(2) 그럼에도 안전망으로 PortalConfigSecurity 에서
HeaderWriterFilter.setShouldWriteHeadersEagerly(true) 를 적용해(spring.io 권고 우회책)
요청 시작 시점에 헤더를 기록하도록 했다.
수정본 5.7.22 / 5.8.24 는 Enterprise 전용이라 OSS 로는 올릴 수 없다. 이미 5.7.x OSS 마지막인
5.7.14 로 올려 둔 상태다.
확인: Rinjae / 2026-08-18 / 동일 스택(Boot 2.7.18 + Security 5.7.14 + Tomcat 9.0.120) 프로브 앱 실측 —
우회책 미적용 시 setHeader/setIntHeader/addIntHeader 경로에서 헤더 누락 재현, 적용 후 정상 기록.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.security/spring-security-.*@5\.7\..*$</packageUrl>
<cve>CVE-2026-22732</cve>
</suppress>
<!-- ================================================================================
Spring Boot High 4건 (2026-09-15 리포트 기준, spring-boot-2.7.18.jar 에 탐지됨).
공통 배경: 4건 모두 수정본이 2.7.33 이며 2.7.x 는 OSS EOL(마지막 OSS 릴리스 2.7.18)이라
상용(Enterprise/Tanzu) 구독 없이는 버전 상향으로 없앨 수 없다. 위 Critical 5건과 같은 제약이다.
아래는 "이 앱의 설정/배포 형태에 트리거 경로가 없음"을 근거로 한 억제이며 만료일을 둔다.
재검토 시 확인할 것: (1) 각 근거 grep 이 여전히 0건인지 (2) OSS 수정본이 나왔는지
(3) Boot 3 이관 여부.
================================================================================ -->
<!-- 6. Spring Boot: CloudFoundry Actuator 인증 우회 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-22733 은 CloudFoundry Actuator 엔드포인트 경로(/cloudfoundryapplication) 아래에
인증이 필요한 애플리케이션 엔드포인트가 선언됐을 때 인증을 우회당하는 문제다.
취약 코드는 spring-boot-actuator-autoconfigure 의 cloudfoundry 패키지에 있고,
해당 자동설정은 CloudFoundry 런타임(VCAP_APPLICATION)에서만 활성화된다.
(1) 배포 산출물에 actuator 계열 jar 가 없다. build.gradle 의 localOnlyLibPrefixes 가
war/bootWar classpath 에서 devtools/actuator/micrometer/spring-boot-admin 을 제거한다.
(2) CloudFoundry 가 아니라 WebLogic 에 WAR 로 배포한다. CF 관련 설정/코드도 없다.
수정본 2.7.33 은 상용 릴리스라 OSS 로는 올릴 수 없다.
확인: Rinjae / 2026-09-15 / war·bootWar classpath 실측 — actuator/devtools/micrometer/SBA 0건
(spring-boot-2.7.18.jar 본체만 포함). 3개 모듈 전체 grep -rE "cloudfoundry|VCAP_" 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
<cve>CVE-2026-22733</cve>
</suppress>
<!-- 7. Spring Boot DevTools: remote secret 타이밍 공격 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-40972 는 DevTools 원격 연결의 secret 비교가 상수시간이 아니어서 타이밍 공격으로
secret 을 알아내고 원격 클래스 업로드(RCE)까지 갈 수 있는 문제다.
(1) 서버측 원격 DevTools 는 spring.devtools.remote.secret 이 설정돼야만 활성화된다.
3개 모듈 전체에 해당 프로퍼티가 없다. yml 의 devtools 설정은 restart/livereload 뿐이고
restart 는 모든 프로파일에서 enabled: false 다.
(2) 배포 산출물에 devtools jar 자체가 없다. developmentOnly 로 선언돼 runtimeClasspath
(= Dependency-Check 스캔 대상)에는 올라오지만 war/bootWar 에서는 제외된다.
수정본 2.7.33 은 상용 릴리스라 OSS 로는 올릴 수 없다.
확인: Rinjae / 2026-09-15 / grep -rE "devtools\.remote|remote\.secret|RemoteSpringApplication" 0건,
war·bootWar classpath 에 spring-boot-devtools 0건(스캔 대상 libs 에는 1건 존재).
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
<cve>CVE-2026-40972</cve>
</suppress>
<!-- 8. Spring Boot: ApplicationTemp 디렉터리 선점 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-40973 은 같은 호스트의 로컬 공격자가 ApplicationTemp 가 쓰는 임시 디렉터리를
선점했을 때 성립한다. 실제 피해(세션 탈취/가젯체인)는 server.servlet.session.persistent=true
로 세션을 파일에 영속화하고 그 상태가 재기동을 넘어 유지될 때의 이야기다.
(1) server.servlet.session.persistent 를 설정하지 않는다(기본 false). 3개 모듈 grep 0건.
(2) 이 포털의 세션 상태는 파일이 아니라 DB 에 보관한다(apps/session/, DatabaseSessionVerifier).
(3) 운영 배포는 WebLogic WAR 이라 세션 처리 주체가 내장 Tomcat 이 아니다.
spring-boot-2.7.18.jar 본체는 배포본에 실리므로 오탐이 아니라 "설정상 트리거 없음" 억제다.
수정본 2.7.33 은 상용 릴리스라 OSS 로는 올릴 수 없다.
확인: Rinjae / 2026-09-15 / grep -rE "session\.persistent|ApplicationTemp" 3개 모듈 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
<cve>CVE-2026-40973</cve>
</suppress>
<!-- 9. Spring Boot: ${random.value} 를 secret 으로 쓰면 예측 가능 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-40975 는 프로퍼티 플레이스홀더 ${random.value} / ${random.int} / ${random.long} 이
암호학적으로 안전하지 않아 secret 용도로 쓰면 예측 가능하다는 문제다(${random.uuid} 는 무관).
이 프로젝트는 ${random.*} 플레이스홀더를 어디에서도 쓰지 않는다.
(참고: 인증토큰/임시비밀번호 등 실제 비밀값 생성은 애플리케이션 코드에서 처리하며
이 CVE 의 대상인 RandomValuePropertySource 와 무관하다.)
spring-boot-2.7.18.jar 본체는 배포본에 실리므로 "미사용" 근거의 억제다.
수정본 2.7.33 은 상용 릴리스라 OSS 로는 올릴 수 없다.
확인: Rinjae / 2026-09-15 / grep -rE "random\.value|random\.int|random\.long" 3개 모듈 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
<cve>CVE-2026-40975</cve>
</suppress>
<!-- 10. elink-common-data: 이더리움 토큰 CVE 오탐 (패키지명 'eai' 매칭) -->
<suppress>
<notes><![CDATA[
오탐. CVE-2019-15079 는 'EAI' 라는 이더리움 ERC-20 토큰의 스마트 컨트랙트(Solidity) 생성자에
오타가 있어 토큰을 무상 취득할 수 있는 문제다. CPE 가 cpe:2.3:a:eai_project:eai:*:* 이며
자바 라이브러리와는 아무 관련이 없다.
오탐이 난 이유: elink-common-data-4.5.5.jar 는 MANIFEST 에 Manifest-Version 한 줄뿐이고
pom.properties 도 없어서 Dependency-Check 가 벤더/제품을 판단할 근거가 없다. 그래서
유일한 패키지 경로인 com/eactive/eai/data 에서 제품명을 'eai' 로 추론해 위 CPE 에 매칭했다.
여기서 eai 는 사내 EAI(Enterprise Application Integration) 의 약어다.
버전을 고정하지 않은 이유: 원인이 패키지명이라 이 아티팩트의 버전을 올려도 같은 오탐이 재발한다.
확인: Rinjae / 2026-09-15 / jar 내부 실측 — 최상위 패키지 com/eactive/eai/data 단일,
.sol/ethereum/token 관련 파일 0건, 총 45개 항목. SHA-1 7e2b7168604d4fbd8c383a4cd7558e8581477a78
로 리포트 대상 jar 와 동일함을 확인. NVD 원문 CPE 대조 완료.
]]></notes>
<packageUrl regex="true">^pkg:maven/com\.eactive\.elink\.common/elink-common-data@.*$</packageUrl>
<cve>CVE-2019-15079</cve>
</suppress>
<!-- 11. spring-boot-admin-client: SBA 서버 SSTI 오탐 -->
<suppress>
<notes><![CDATA[
오탐. CVE-2023-38286 은 Thymeleaf 3.1.1.RELEASE 이하의 샌드박스 우회를 Spring Boot Admin
서버에서 SSTI/코드실행으로 엮을 수 있다는 내용이고, 성립 조건이 "MailNotifier 활성화 +
UI 를 통한 환경변수 쓰기 권한"이다. 세 가지 이유로 이 프로젝트에는 성립하지 않는다.
(1) 우리가 쓰는 건 서버가 아니라 client 다. spring-boot-admin-client-2.7.16.jar 안에는
de/codecentric/boot/admin/client/** 의 등록(registration)·설정 클래스뿐이고
MailNotifier / thymeleaf 템플릿 / html 리소스가 0건이다. 취약 코드가 있는
spring-boot-admin-server 는 의존성에 아예 없다.
(2) thymeleaf 를 3.1.5.RELEASE 로 올려 두어 취약 범위(3.1.1 이하)를 벗어난다. 위 2번 항목 참고.
(3) 배포 산출물에 SBA jar 자체가 없다. build.gradle 의 localOnlyLibPrefixes 가
war/bootWar classpath 에서 spring-boot-admin-* 을 제거한다(로컬 모니터링 전용).
NVD 의 CPE 는 cpe:2.3:a:codecentric:spring_boot_admin:* (versionEndIncluding 3.1.0) 이라
client/server 구분이 없어 2.7.16 도 범위에 들어온다. Dependency-Check 가 이 CPE 를
client 아티팩트에 그대로 매칭한 것이 오탐의 원인이다.
확인: Rinjae / 2026-09-15 / jar 내부 실측 — MailNotifier/thymeleaf/html 0건,
SHA-1 04c3df4a292fb879555f61db19e5bf0b7c8fc54b 로 리포트 대상과 동일.
war classpath 실측 — spring-boot-admin-* 0건, thymeleaf-3.1.5.RELEASE.jar 포함.
]]></notes>
<packageUrl regex="true">^pkg:maven/de\.codecentric/spring-boot-admin-.*@.*$</packageUrl>
<cve>CVE-2023-38286</cve>
</suppress>
</suppressions>
+92
View File
@@ -0,0 +1,92 @@
## OHS Static Resource 설정 예시
```apacheconf
<VirtualHost *:443>
ServerName weblogic-djb.rinjae.kr
<IfModule ossl_module>
SSLEngine on
SSLVerifyClient None
SSLProtocol TLSv1.2 TLSv1.3
SSLHonorCipherOrder on
SSLCipherSuite TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
SSLWallet "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/keystores/default"
<FilesMatch "\.(cgi|shtml|phtml|php)$">
SSLOptions +StdEnvVars
</FilesMatch>
<Directory "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/cgi-bin">
SSLOptions +StdEnvVars
</Directory>
BrowserMatch "MSIE [2-5]" \
nokeepalive ssl-unclean-shutdown \
downgrade-1.0 force-response-1.0
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=63072000; preload; includeSubDomains"
</IfModule>
</IfModule>
Alias /css /apps/portal-static/css
Alias /js /apps/portal-static/js
Alias /img /apps/portal-static/img
Alias /images /apps/portal-static/images
Alias /webfonts /apps/portal-static/webfonts
Alias /font /apps/portal-static/font
Alias /html /apps/portal-static/html
Alias /plugins /apps/portal-static/plugins
Alias /favicon.png /apps/portal-static/favicon.png
<Directory "/apps/portal-static">
Require all granted
Options -Indexes
</Directory>
<LocationMatch "^/favicon\.png$|^/(css|js|img|images|webfonts|font|html|plugins)/">
Header set Cache-Control "no-cache"
</LocationMatch>
<IfModule weblogic_module>
<Location />
DirectoryIndex disabled
SetHandler weblogic-handler
WebLogicHost 172.30.1.100
WebLogicPort 39130
DynamicServerList OFF
ConnectTimeoutSecs 10
ConnectRetrySecs 2
WLProxySSL ON
</Location>
</IfModule>
<Location /css>
SetHandler None
</Location>
<Location /js>
SetHandler None
</Location>
<Location /img>
SetHandler None
</Location>
<Location /images>
SetHandler None
</Location>
<Location /webfonts>
SetHandler None
</Location>
<Location /font>
SetHandler None
</Location>
<Location /html>
SetHandler None
</Location>
<Location /plugins>
SetHandler None
</Location>
<Location /favicon.png>
SetHandler None
</Location>
</VirtualHost>
```
+343
View File
@@ -0,0 +1,343 @@
import argparse
from datetime import datetime
import http.cookiejar
from http.cookies import SimpleCookie
import json
import ssl
import sys
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_HOST = 'https://api.jejubank.co.kr'
COOKIE_NAME = 'JSESSIONID_PORTAL'
USER_AGENT = (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/140.0.0.0 Safari/537.36'
)
COLUMNS = (
('', 3),
('대상', 4),
('결과', 10),
('쿠키', 8),
('CSRF', 8),
('HTTP', 4),
('요청 값', 10),
('요청 서버ID', 11),
('응답 값', 10),
('응답 서버ID', 11),
)
BORDER = '+' + '+'.join('-' * (width + 2) for _, width in COLUMNS) + '+'
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, request, fp, code, message, headers, new_url):
# 두 서버 비교에서는 지정한 주소의 첫 응답만 검사한다.
# 공유 세션 쿠키를 리다이렉트 대상에 전달하지 않는다.
return None
def integer_between(minimum, maximum):
def parse(value):
try:
number = int(value)
except ValueError:
raise argparse.ArgumentTypeError(
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
)
if not minimum <= number <= maximum:
raise argparse.ArgumentTypeError(
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
)
return number
return parse
def host_address(value):
value = value.strip()
message = 'http://호스트[:포트] 또는 https://호스트[:포트] 형식으로 입력하세요.'
try:
parsed = urllib.parse.urlsplit(value)
parsed.port # 포트 형식과 범위도 검사한다.
except ValueError:
raise argparse.ArgumentTypeError(message)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
or parsed.path not in ('', '/') or parsed.query or parsed.fragment
or parsed.username is not None or parsed.password is not None
or any(char.isspace() for char in value)):
raise argparse.ArgumentTypeError(message)
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description='새 익명 세션으로 쿠키와 CSRF 토큰의 유지 여부를 표로 확인합니다.'
)
parser.add_argument(
'--host', type=host_address, default=DEFAULT_HOST,
metavar='주소', help=f'대상 호스트 주소 (기본값: {DEFAULT_HOST})',
)
parser.add_argument(
'--host2', type=host_address, metavar='주소',
help='두 번째 서버. A/B를 번갈아 요청 (리다이렉트 미추적)',
)
parser.add_argument(
'--cookie-mode', choices=('shared', 'separate'), default='shared',
help='shared: 직전 세션 쿠키 공유, separate: 서버 주소/포트별 저장소 분리 (기본값: shared)',
)
parser.add_argument(
'-n', '--count', type=integer_between(1, 100), default=10,
metavar='횟수', help='전체 요청 횟수: 1~100회 (기본값: 10회, 두 서버 모드도 합산)',
)
parser.add_argument(
'-d', '--delay', '--delay-ms', type=integer_between(0, 10000), default=50,
metavar='밀리초', help='요청 사이 대기시간: 0~10000ms (기본값: 50ms)',
)
tls_options = parser.add_mutually_exclusive_group()
tls_options.add_argument(
'--cacert', metavar='CA파일',
help='신뢰할 사설 CA 인증서 또는 인증서 묶음 파일 (PEM 형식)',
)
tls_options.add_argument(
'-k', '--insecure', action='store_true',
help='HTTPS 서버 인증서와 호스트명 검증 생략',
)
return parser.parse_args(argv)
def tls_context(args):
context = ssl.create_default_context()
if args.cacert:
context.load_verify_locations(cafile=args.cacert)
if args.insecure:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def cookie_from_headers(headers):
value = None
for header in headers:
parsed = SimpleCookie()
parsed.load(header)
if COOKIE_NAME in parsed:
value = parsed[COOKIE_NAME].value
return value
def cookie_for_url(jar, url):
request = urllib.request.Request(url)
jar.add_cookie_header(request)
return cookie_from_headers([request.get_header('Cookie', '')])
def host_key(url):
parsed = urllib.parse.urlsplit(url)
port = parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)
return parsed.scheme, parsed.hostname, port
def carry_session_cookie(request, value):
# 현재 호스트에 해당하는 다른 쿠키는 유지하고 포털 세션 쿠키만 이어 보낸다.
cookies = SimpleCookie()
cookies.load(request.get_header('Cookie', ''))
cookies[COOKIE_NAME] = value
request.add_unredirected_header(
'Cookie', cookies.output(header='', sep='; ').strip()
)
def server_id(cookie):
return cookie.split('!')[1] if cookie and '!' in cookie else '-'
def cell(value, width):
text = str(value)
# 한글은 보통 터미널에서 두 칸을 차지하므로 표시 폭을 기준으로 정렬한다.
display_width = sum(
0 if unicodedata.combining(char) else
2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1
for char in text
)
return text + ' ' * max(0, width - display_width)
def print_row(values):
print('| ' + ' | '.join(
cell(value, width) for value, (_, width) in zip(values, COLUMNS)
) + ' |', flush=True)
def change_state(previous, current):
if previous is None:
return '최초'
return '유지' if previous == current else '!!변경!!'
def run_probe(args):
targets = [('A', args.host + '/api/session/csrf')]
if args.host2:
targets.append(('B', args.host2 + '/api/session/csrf'))
try:
context = tls_context(args)
except (OSError, ValueError) as error:
print(f'TLS 설정 실패: {error}', file=sys.stderr)
return 1
jar = http.cookiejar.CookieJar()
cookie_processor = urllib.request.HTTPCookieProcessor(jar)
handlers = [
urllib.request.HTTPSHandler(context=context),
cookie_processor,
]
if args.host2:
handlers.append(NoRedirectHandler())
client = urllib.request.build_opener(*handlers)
previous_cookie = None
previous_token = None
cookie_stores = {}
session_states = {}
totals = {'최초': 0, '유지': 0, '변경': 0}
completed = 0
failure = None
exit_code = 0
for target, url in targets:
print(f'대상 {target}: {url}')
if len(targets) == 2:
print('호출 순서: A -> B -> A -> B ... (클라이언트 1개, 전체 요청 횟수 기준)')
print('두 서버 비교에서는 3xx 리다이렉트를 따라가지 않습니다.')
if args.cookie_mode == 'separate':
print('쿠키 모드: separate | 서버 주소/포트별 저장소 및 변경 비교 기준 분리')
elif len(targets) == 2:
print(f'쿠키 모드: shared | 직전 {COOKIE_NAME}을 다음 서버로 전달')
if any(url.startswith('https://') for _, url in targets):
tls_mode = ('생략 (--insecure)' if args.insecure else
f'사설 CA 추가 ({args.cacert})' if args.cacert else '기본 CA 사용')
print(f'TLS 인증서 검증: {tls_mode}')
print(f'쿠키 키: {COOKIE_NAME} | 값: 앞 10자리 | 횟수: {args.count}회 | 딜레이: {args.delay}ms')
print(BORDER)
print_row([title for title, _ in COLUMNS])
print(BORDER, flush=True)
try:
for number in range(1, args.count + 1):
if number > 1 and args.delay:
time.sleep(args.delay / 1000)
target, url = targets[(number - 1) % len(targets)]
if args.cookie_mode == 'separate':
key = host_key(url)
if key not in cookie_stores:
cookie_stores[key] = http.cookiejar.CookieJar()
jar = cookie_stores[key]
# 순차 요청마다 같은 클라이언트의 쿠키 저장소만 교체한다.
cookie_processor.cookiejar = jar
previous_cookie, previous_token = session_states.get(key, (None, None))
request = urllib.request.Request(
url,
headers={
'Accept': 'application/json',
'Cache-Control': 'no-cache',
'User-Agent': USER_AGENT,
},
)
# 응답이 저장소를 갱신하기 전에 실제 전송할 쿠키를 기록한다.
jar.add_cookie_header(request)
if args.host2 and args.cookie_mode == 'shared' and previous_cookie is not None:
carry_session_cookie(request, previous_cookie)
request_cookie = cookie_from_headers([request.get_header('Cookie', '')])
response_cookie = None
status = '-'
try:
with client.open(request, timeout=10) as response:
status = response.status
response_url = response.geturl()
# 이번 응답의 Set-Cookie만 표시한다. 재발급이 없으면 '없음'.
response_cookie = cookie_from_headers(
response.headers.get_all('Set-Cookie') or []
)
data = json.load(response)
if args.host2 and args.cookie_mode == 'shared' and response_cookie is None:
# 다른 호스트에 직접 이어 보낸 쿠키는 저장소에 없을 수 있다.
# 재발급이 없으면 이번 요청에 실었던 세션을 계속 사용한다.
cookie = request_cookie
else:
cookie = cookie_for_url(jar, response_url)
token = data.get('token') if isinstance(data, dict) else None
if not cookie or not token:
raise ValueError('세션 쿠키 또는 CSRF 토큰 없음')
except urllib.error.HTTPError as error:
status = error.code
response_cookie = cookie_from_headers(
error.headers.get_all('Set-Cookie') or []
)
failure = f'{number}회: HTTP {status}'
error.close()
except Exception as error:
failure = f'{number}회: {error}'
if failure:
result, cookie_state, token_state = '!!실패!!', '-', '-'
exit_code = 1
else:
# 변경 판정은 앞 10자리가 아니라 전체 쿠키와 전체 토큰으로 비교한다.
cookie_state = change_state(previous_cookie, cookie)
token_state = change_state(previous_token, token)
if '!!변경!!' in (cookie_state, token_state):
outcome, result = '변경', '>>>변경<<<'
elif previous_cookie is None:
outcome, result = '최초', '[최초]'
else:
outcome, result = '유지', '[유지]'
totals[outcome] += 1
completed += 1
previous_cookie, previous_token = cookie, token
if args.cookie_mode == 'separate':
session_states[host_key(url)] = (cookie, token)
print_row((
number, target, result, cookie_state, token_state, status,
request_cookie[:10] if request_cookie else '없음',
server_id(request_cookie),
response_cookie[:10] if response_cookie else '없음',
server_id(response_cookie),
))
if failure:
break
except KeyboardInterrupt:
failure = '사용자가 중단했습니다.'
exit_code = 130
print(BORDER)
print(f'정상 조회: {completed}/{args.count}회 | 최초: {totals["최초"]}회 | 유지: {totals["유지"]}회 | 변경: {totals["변경"]}')
print('응답 값=없음: Set-Cookie 재발급 없음. 서버ID는 각 쿠키의 ! 뒤 식별값입니다.')
if failure:
print(f'실패/중단: {failure}')
return exit_code
def main(argv=None):
args = parse_args(argv)
started_counter = time.perf_counter()
started_at = datetime.now().astimezone()
print(f'시작 시각: {started_at.isoformat(sep=" ", timespec="milliseconds")}', flush=True)
try:
return run_probe(args)
except KeyboardInterrupt:
print('실패/중단: 사용자가 중단했습니다.')
return 130
finally:
finished_at = datetime.now().astimezone()
# 시스템 시각 보정에 영향받지 않도록 경과 시간은 별도 시계로 측정한다.
elapsed = time.perf_counter() - started_counter
print(f'종료 시각: {finished_at.isoformat(sep=" ", timespec="milliseconds")}')
print(f'총 소요 시간: {elapsed:.3f}초 (요청 간 딜레이 포함)', flush=True)
if __name__ == '__main__':
sys.exit(main())
+21
View File
@@ -0,0 +1,21 @@
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
// Read-only dependency resolution; does not override versions or the normal build.
gradle.projectsEvaluated {
def portal = gradle.rootProject
portal.tasks.register('thymeleafCompatibilityDependencies') {
doLast {
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
output.mkdirs()
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
def rows = artifacts.findAll {
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
}.collect {
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
}.sort()
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
}
println "Dependency evidence: ${output}"
}
}
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
import argparse
import hashlib
import io
import json
from pathlib import Path
import re
import zipfile
EXPECTED_CHANGES = {
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
"ognl:ognl": ("3.3.4", "3.3.5"),
}
EXPECTED_CLASSES = {
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
"ognl/Ognl.class": "ognl-3.3.5.jar",
}
LOCAL_PREFIXES = (
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
)
def require(condition, message):
if not condition:
raise ValueError(message)
def dependencies(path):
result = {}
for line in path.read_text().splitlines():
module, version, filename = line.split("\t")
entries = result.setdefault(module, [])
require((version, filename) not in entries, "Duplicate artifact: " + filename)
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
entries.append((version, filename))
for entries in result.values():
entries.sort()
return result
def compare(before, after):
old, new = dependencies(before), dependencies(after)
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
return {"external_modules": len(new), "changes": changed}
def inspect_war(path):
owners = {name: [] for name in EXPECTED_CLASSES}
with zipfile.ZipFile(path) as war:
names = war.namelist()
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
jars = sorted(name for name in names if name.endswith(".jar"))
basenames = [Path(name).name for name in jars]
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
for name in names), "Local profile packaged: " + str(path))
for name in jars:
basename = Path(name).name
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
classes = set(jar.namelist())
require(not any(c.startswith("jakarta/servlet/") for c in classes),
"Jakarta Servlet classes packaged: " + name)
for target in owners:
if target in classes:
owners[target].append(name)
# Java 8 compatibility of each upgraded library's entry class.
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
"Java >8 class: " + name + "!" + target)
for target, expected in EXPECTED_CLASSES.items():
require(owners[target] == ["WEB-INF/lib/" + expected],
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
for pattern, expected in [
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
(r"ognl-", "ognl-3.3.5.jar"),
]:
require([n for n in basenames if re.match(pattern, n)] == [expected],
"Wrong/duplicate library version for " + expected)
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"jar_count": len(jars), "class_providers": owners, "jars": jars}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
parser.add_argument("--war", type=Path, action="append", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
result = {
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
"wars": [inspect_war(path) for path in args.war],
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
print("PASS: dependency changes limited to three modules; both WARs verified")
for war in result["wars"]:
print(war["file"], war["sha256"])
if __name__ == "__main__":
main()
+542
View File
@@ -9,9 +9,454 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.63.0",
"esbuild": "^0.25.0",
"node-forge": "^1.3.1",
"sass": "^1.69.5"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
@@ -322,6 +767,22 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -349,6 +810,48 @@
"node": ">=8"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -389,6 +892,16 @@
"license": "MIT",
"optional": true
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"dev": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -403,6 +916,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+7 -2
View File
@@ -1,15 +1,20 @@
{
"name": "eapim-portal",
"version": "1.0.0",
"description": "SASS build system for EAPIM Portal",
"description": "SASS build system + forge custom bundle for EAPIM Portal",
"scripts": {
"test:moment": "playwright test --config=src/test/js/playwright.config.js",
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
"build": "npm run sass:build && npm run sass:build:minified",
"dev": "npm run sass:watch"
"dev": "npm run sass:watch",
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"esbuild": "^0.25.0",
"node-forge": "^1.3.1",
"sass": "^1.69.5"
},
"author": "",
+17
View File
@@ -27,6 +27,23 @@ sonar.java.source=8
sonar.java.binaries=build/classes/java/main
sonar.java.test.binaries=build/classes/java/test
# --- 커버리지 --------------------------------------------------------------
# JaCoCo XML 경로. build.gradle 의 test 가 finalizedBy jacocoTestReport 로 생성한다.
# 이 값이 없으면 Sonar 는 커버리지를 "미측정"이 아니라 0% 로 집계한다(Zero Coverage Sensor).
# Jenkins 는 파일 존재 여부를 확인한 뒤 -D 로 한 번 더 덮어쓴다.
sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml
# 커버리지 집계에서 빼는 대상.
# 주의: JaCoCo 쪽에서 클래스를 빼면 Sonar 가 0% 로 채우므로, 제외는 반드시 여기서 한다.
# 실행 로직이 없어 테스트 대상이 아닌 것들만 최소로 지정한다.
sonar.coverage.exclusions=\
**/PortalApplication.java,\
**/config/**,\
**/dto/**,\
**/*Dto.java,\
**/entity/**,\
**/mapper/**
# --- 제외 대상 -------------------------------------------------------------
# 서드파티 번들/컴파일 산출물/바이너리. 분석해도 우리가 고칠 수 없는 코드는 뺀다.
sonar.exclusions=\
@@ -5,19 +5,36 @@ import java.sql.SQLException;
import javax.sql.DataSource;
import org.jboss.logging.Logger;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.spring5.SpringTemplateEngine;
import com.google.common.util.concurrent.Futures;
/**
* Readiness probe.
*
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
* This endpoint validates both EMS and Gateway datasources via JDBC
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
* requests that depend on the database.
* {@code Connection.isValid(timeout)}, plus three libraries that WebLogic's
* oracle_common bundles at a different version from this app (see weblogic.xml
* {@code prefer-application-packages}): Thymeleaf's {@link SpringTemplateEngine}
* (jackson), jboss-logging, and Guava.
*
* <p>These exist because this controller bypasses view resolution entirely
* (plain {@code @RestController} JSON) — a classpath split between the app's
* copy and WebLogic's bundled copy of a preferred package can throw
* {@link LinkageError}/{@link java.util.ServiceConfigurationError} only on the
* real code paths that touch it, while this probe kept reporting 200. That
* already happened once for jackson (Thymeleaf's dialect/module discovery
* failed on every real page while /health/ready stayed green) — jboss-logging
* and Guava carry the same risk (confirmed version mismatch against
* oracle_common, not yet observed failing in production) so they get the same
* kind of forced-touch check here.
*
* <p>HTTP 200 + JSON when all checks pass.
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
@@ -30,24 +47,34 @@ public class ReadinessController {
private final DataSource portalDataSource;
private final DataSource gatewayDataSource;
private final SpringTemplateEngine templateEngine;
public ReadinessController(
@Qualifier("portalDataSource") DataSource portalDataSource,
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
@Qualifier("gatewayDataSource") DataSource gatewayDataSource,
SpringTemplateEngine templateEngine) {
this.portalDataSource = portalDataSource;
this.gatewayDataSource = gatewayDataSource;
this.templateEngine = templateEngine;
}
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> ready() {
String ems = check(portalDataSource);
String gw = check(gatewayDataSource);
boolean ok = "UP".equals(ems) && "UP".equals(gw);
String ems = check(portalDataSource);
String gw = check(gatewayDataSource);
String view = checkTemplateEngine();
String logging = checkJbossLogging();
String guava = checkGuava();
boolean ok = "UP".equals(ems) && "UP".equals(gw) && "UP".equals(view)
&& "UP".equals(logging) && "UP".equals(guava);
String body = "{"
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\","
+ "\"gateway\":\""+ gw + "\""
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\","
+ "\"gateway\":\"" + gw + "\","
+ "\"view\":\"" + view + "\","
+ "\"logging\":\"" + logging + "\","
+ "\"guava\":\"" + guava + "\""
+ "}";
return ResponseEntity.status(ok ? 200 : 503)
@@ -55,6 +82,49 @@ public class ReadinessController {
.body(body);
}
/** {@code getConfiguration()} lazily runs Thymeleaf's dialect/module init on first call
* and caches it — so this is cheap once healthy, and reproduces the exact failure path
* a real page render would hit. Catches {@link Throwable}: init failures here have
* surfaced as {@link Error} (ServiceConfigurationError), not just Exception. */
private String checkTemplateEngine() {
try {
templateEngine.getConfiguration();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
/** Forces jboss-logging's {@code Logger} + its bound provider (LogManager/JBoss/JUL
* discovery) to load and actually run a log-level check, not just resolve the class. */
private static String checkJbossLogging() {
try {
Logger log = Logger.getLogger(ReadinessController.class);
log.isDebugEnabled();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
/** {@code Futures.immediateFuture} touches Guava's {@code util.concurrent} machinery
* (AbstractFuture/InternalFutureFailureAccess) — the part backed by the separate
* {@code failureaccess} jar, which is exactly where an app/WebLogic version mismatch
* would split across classloaders. */
private static String checkGuava() {
try {
Futures.immediateFuture(Boolean.TRUE).isDone();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
private static String down(Throwable t) {
String msg = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
}
private static String check(DataSource ds) {
if (ds == null) return "DOWN:NO_DATASOURCE";
try (Connection c = ds.getConnection()) {
@@ -1,10 +1,16 @@
package com.eactive.apim.portal.apps.agreements.controller;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService;
import com.eactive.apim.portal.apps.agreements.dto.AgreementTabDTO;
import com.eactive.apim.portal.apps.agreements.dto.AgreementsDTO;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -21,38 +27,56 @@ public class AgreementsController {
public static final String PRIVACY_POLICY_EXTERNAL_URL =
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
/**
* 약관 종류별 {@code tab} 파라미터 값. 기존 북마크/외부 링크 호환을 위해 슬러그를 유지한다.
* (초대 팝업 등에서 {@code tab=notification} 으로 직접 링크한다)
*/
private static final Map<AgreementType, String> TAB_SLUGS = new EnumMap<>(AgreementType.class);
static {
TAB_SLUGS.put(AgreementType.TERMS_OF_USE, "terms");
TAB_SLUGS.put(AgreementType.PRIVACY_POLICY, "privacy");
TAB_SLUGS.put(AgreementType.PRIVACY_COLLECT, "privacy-collect");
TAB_SLUGS.put(AgreementType.PRIVACY_COLLECT_IND, "privacy-collect-ind");
TAB_SLUGS.put(AgreementType.PRIVACY_COLLECT_ORG, "privacy-collect-org");
TAB_SLUGS.put(AgreementType.NOTIFICATION_CONSENT, "notification");
}
private final AgreementsFacade agreementsFacade;
private final AgreementTypeConfigService agreementTypeConfigService;
@Autowired
public AgreementsController(AgreementsFacade agreementsFacade) {
public AgreementsController(AgreementsFacade agreementsFacade,
AgreementTypeConfigService agreementTypeConfigService) {
this.agreementsFacade = agreementsFacade;
this.agreementTypeConfigService = agreementTypeConfigService;
}
@GetMapping("/terms")
public String showTerms(@RequestParam(required = false) String tab,
@RequestParam(required = false) String publishedOn,
Model model) {
String currentTab = tab != null ? tab : "terms";
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
if ("privacy".equals(currentTab)) {
if ("privacy".equals(tab)) {
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
}
AgreementType type;
switch (currentTab) {
case "privacy-collect":
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
type = AgreementType.PRIVACY_COLLECT;
break;
case "notification":
type = AgreementType.NOTIFICATION_CONSENT;
break;
case "terms":
default:
currentTab = "terms";
type = AgreementType.TERMS_OF_USE;
break;
// 노출 대상과 순서는 관리 콘솔의 '약관 종류 관리'에서 설정한다
List<AgreementType> displayTypes = agreementTypeConfigService.getDisplayTypes();
// 요청한 탭이 노출 대상이 아니면(미배치/사용안함) 첫 번째 탭으로 보정한다
AgreementType type = resolveType(tab, displayTypes);
if (type == null) {
// 노출할 약관 종류가 하나도 없는 경우 — 빈 화면으로 방어
model.addAttribute("termsTabs", Collections.<AgreementTabDTO>emptyList());
model.addAttribute("agreementsList", Collections.<AgreementsDTO>emptyList());
model.addAttribute("selectedAgreement", null);
model.addAttribute("selectedDate", publishedOn);
model.addAttribute("agreementTitle", "약관");
model.addAttribute("agreementType", null);
model.addAttribute("currentTab", null);
return TERMS_AGREEMENTS;
}
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
@@ -68,17 +92,40 @@ public class AgreementsController {
model.addAttribute("selectedAgreement", selectedAgreement);
model.addAttribute("selectedDate", publishedOn);
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
model.addAttribute("termsTabs", buildTabs(displayTypes, type));
model.addAttribute("agreementTitle", type.getDescription());
model.addAttribute("agreementType", type.getCode());
model.addAttribute("currentTab", currentTab);
model.addAttribute("currentTab", TAB_SLUGS.get(type));
return TERMS_AGREEMENTS;
}
/** 요청한 탭 슬러그를 노출 대상 약관 종류로 해석한다. 없거나 노출 대상이 아니면 첫 번째 탭. */
private AgreementType resolveType(String tab, List<AgreementType> displayTypes) {
if (displayTypes.isEmpty()) {
return null;
}
if (tab != null && !tab.isEmpty()) {
for (AgreementType displayType : displayTypes) {
if (tab.equals(TAB_SLUGS.get(displayType))) {
return displayType;
}
}
}
return displayTypes.get(0);
}
private List<AgreementTabDTO> buildTabs(List<AgreementType> displayTypes, AgreementType currentType) {
List<AgreementTabDTO> tabs = new ArrayList<>();
for (AgreementType displayType : displayTypes) {
tabs.add(new AgreementTabDTO(
TAB_SLUGS.get(displayType),
displayType.getDescription(),
displayType == currentType));
}
return tabs;
}
private AgreementsDTO findAgreementByDate(List<AgreementsDTO> agreements, String publishedOn) {
return agreements.stream()
.filter(a -> publishedOn.equals(
@@ -0,0 +1,24 @@
package com.eactive.apim.portal.apps.agreements.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 약관 페이지({@code /agreements/terms})의 탭 하나.
*
* <p>노출 대상과 순서는 관리 콘솔의 '약관 종류 관리'에서 설정한 값
* ({@code PTL_PROPERTY Portal/portal.terms.display-types})을 따른다.
*/
@Getter
@AllArgsConstructor
public class AgreementTabDTO {
/** URL 파라미터 {@code tab} 값 (북마크 호환을 위해 기존 슬러그를 유지한다) */
private final String tab;
/** 탭에 표시할 약관명 */
private final String title;
/** 현재 선택된 탭인지 여부 */
private final boolean active;
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.agreements.service;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.ui.Model;
import java.util.Set;
/**
* 약관 동의 폼({@code apps/register/userAgreementContent :: agreementContent})에 필요한 모델 속성을 채운다.
*
* <p>관리 콘솔의 '약관 종류 관리'에서 사용하지 않도록 설정한 약관 종류는 동의 항목 자체를 노출하지 않는다
* ({@code show*} 플래그가 {@code false} 이고 내용도 {@code null}).
*
* @see AgreementTypeConfigService
*/
@Component
@RequiredArgsConstructor
public class AgreementFormModelSupport {
private final AgreementsFacade agreementsFacade;
private final AgreementTypeConfigService agreementTypeConfigService;
/** 이용약관 / 개인정보수집동의서 / 알림 수신 동의서 모델 속성을 사용 여부에 맞춰 채운다. */
public void applyAgreements(Model model) {
applyAgreeAllMode(model);
Set<AgreementType> enabledTypes = agreementTypeConfigService.getEnabledTypes();
applyAgreement(model, enabledTypes, AgreementType.TERMS_OF_USE, "termsOfUse", "showTermsOfUse");
applyAgreement(model, enabledTypes, AgreementType.PRIVACY_COLLECT, "privacyCollect", "showPrivacyCollect");
applyAgreement(model, enabledTypes, AgreementType.NOTIFICATION_CONSENT, "notificationConsent",
"showNotificationConsent");
}
/**
* '전체 동의' 허용 여부만 모델에 담는다.
*
* <p>동의 항목 구성은 그대로 두고 동의 방식만 설정에 맞추면 되는 화면(법인 전환 등)에서 쓴다.
* {@code false} 면 '전체 동의' 체크박스를 숨기고 항목별로 끝까지 읽어야 동의할 수 있다.
*/
public void applyAgreeAllMode(Model model) {
model.addAttribute("agreeAllEnabled", agreementTypeConfigService.isAgreeAllEnabled());
}
private void applyAgreement(Model model, Set<AgreementType> enabledTypes, AgreementType type,
String contentAttribute, String flagAttribute) {
boolean enabled = enabledTypes.contains(type);
model.addAttribute(flagAttribute, enabled);
model.addAttribute(contentAttribute, enabled ? agreementsFacade.getAgreement(type.getCode()) : null);
}
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
@@ -17,6 +18,7 @@ import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
@@ -37,6 +39,7 @@ public class ApiController {
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private final ApiListProperties apiListProperties;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -88,7 +91,8 @@ public class ApiController {
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort());
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, listPageable);
model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services"));
@@ -109,8 +113,8 @@ public class ApiController {
*/
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);
int fromIndex = (int) Math.min(pageable.getOffset(), total);
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
@@ -0,0 +1,35 @@
package com.eactive.apim.portal.apps.apis.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/** OPEN API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 공통 페이지 크기. */
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiListProperties {
private static final String PAGE_SIZE_PROPERTY = "api.list.page-size";
private static final int DEFAULT_PAGE_SIZE = 15;
private final PortalPropertyService portalPropertyService;
/** PTL_PROPERTY(Portal / api.list.page-size)를 조회하고, 없으면 기본값으로 생성한다. */
public int getPageSize() {
String value = portalPropertyService.getOrCreateProperty(
"Portal", PAGE_SIZE_PROPERTY, String.valueOf(DEFAULT_PAGE_SIZE),
"API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 페이지당 노출 개수 (양의 정수, 기본 15)");
try {
int size = Integer.parseInt(value == null ? "" : value.trim());
if (size > 0) {
return size;
}
} catch (NumberFormatException ignored) {
// 비어 있거나 정수가 아닌 설정은 기본값을 사용한다.
}
log.warn("{} 값이 올바르지 않음('{}') - 기본값 {} 사용", PAGE_SIZE_PROPERTY, value, DEFAULT_PAGE_SIZE);
return DEFAULT_PAGE_SIZE;
}
}
@@ -36,6 +36,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.stereotype.Service;
@Service
@@ -155,10 +156,10 @@ public class ApiSpecService {
updateReferences(pathItem, apiId);
String tmpFullPath = StringUtils.isNotEmpty(basePath)
? StringUtils.join(basePath, "/", StringUtils.removeStart(path, "/"))
? StringUtils.join(basePath, "/", Strings.CS.removeStart(path, "/"))
: path;
final String fullPath = StringUtils.replacePattern(tmpFullPath, "//+", "/");
final String fullPath = tmpFullPath.replaceAll("//+", "/");
// HTTP 메소드별로 중복 체크
pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
@@ -294,7 +295,7 @@ public class ApiSpecService {
return StringUtils.EMPTY;
}
Server server = api.getServers().get(0);
return StringUtils.removeEnd(server.getUrl(), "/");
return Strings.CS.removeEnd(server.getUrl(), "/");
}
private void mergeComponents(OpenAPI currentAPI, String apiId, String apiName, Map<String, Object> mergedComponents, ObjectMapper objectMapper) {
@@ -1,18 +1,28 @@
package com.eactive.apim.portal.apps.app.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
*
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
*
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
*/
@Slf4j
@Service
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
private static final String PROP_GROUP = "Portal";
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
private static final String PROP_ADMIN_BASE_URL_DESC =
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
private final RestTemplate restTemplate;
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
/**
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
*
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
*
* @param clientId 차단할 클라이언트 ID
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
*/
public void blockClient(String clientId) {
String baseUrl = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
List<String> baseUrls = resolveBaseUrls();
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
RestClientException lastError = null;
for (int i = 0; i < baseUrls.size(); i++) {
String baseUrl = baseUrls.get(i);
String url = baseUrl + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
ResponseEntity<Map> response;
try {
response = restTemplate.postForEntity(url, null, Map.class, clientId);
} catch (RestClientException e) {
// 통신 계층 실패 — 다음 admin 주소로 failover
lastError = e;
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
continue;
}
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException(
"admin clientBlock 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
}
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
return;
}
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
throw new IllegalStateException(
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
}
/**
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
*
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
*/
private List<String> resolveBaseUrls() {
String raw = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
List<String> urls = parseBaseUrls(raw);
if (urls.isEmpty()) {
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
}
return urls;
}
/**
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
*/
static List<String> parseBaseUrls(String raw) {
Set<String> ordered = new LinkedHashSet<>();
if (raw != null) {
for (String token : raw.split("[,\\r\\n]")) {
String url = stripTrailingSlashes(token.trim());
if (!url.isEmpty()) {
ordered.add(url);
}
}
}
return new ArrayList<>(ordered);
}
/**
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
LocalDate today = LocalDate.now();
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
searchDto.setStartDate(LocalDate.now().minusDays(7));
searchDto.setEndDate(LocalDate.now());
searchDto.setStartDate(today.minusDays(7));
searchDto.setEndDate(today);
searchDto.setClientId(null);
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
model.addAttribute("details", result.getDetails());
model.addAttribute("periods", result.getPeriods());
model.addAttribute("searchDto", searchDto);
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
model.addAttribute("statsMinDate", today.minusYears(1).toString());
model.addAttribute("statsMaxDate", today.toString());
// 월별 선택 가능 월 + 집계 안내 문구 데이터
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
return searchDto.isMonthly()
? "조회할 월이 올바르지 않습니다."
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
+ "일(시작일·종료일 포함)로 선택해주세요.";
}
private PortalOrg getPortalOrg() {
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
public class ApiStatisticsSearchDto {
/**
* 최대 조회 가능 일수 (일별 모드).
* 한 번에 조회 가능 일수 (일별 모드, 시작일·종료일 포함).
*/
public static final int MAX_DATE_RANGE_DAYS = 40;
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
}
/**
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
*/
public boolean isValidDateRange() {
return isValidDateRange(LocalDate.now());
}
boolean isValidDateRange(LocalDate today) {
if (startDate == null || endDate == null) {
return false;
}
if (startDate.isAfter(endDate)) {
return false;
}
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
return daysBetween <= MAX_DATE_RANGE_DAYS;
LocalDate earliestDate = today.minusYears(1);
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
}
/**
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
@@ -51,6 +52,7 @@ public class AccountController {
private final UserFacade userFacade;
private final OrgRegisterFacade orgRegisterFacade;
private final AgreementsFacade agreementsFacade;
private final AgreementFormModelSupport agreementFormModelSupport;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
@@ -365,6 +367,8 @@ public class AccountController {
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("registrationType", "corporate");
// 동의 항목 구성은 이 화면 그대로 두고 '전체 동의' 허용 여부만 설정을 따른다
agreementFormModelSupport.applyAgreeAllMode(model);
return "apps/mypage/orgTransfer";
} catch (Exception e) {
@@ -1,6 +1,6 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
@@ -21,7 +21,7 @@ public class OrgRegisterController {
private static final String MAIN_ORG_REGISTER = "apps/register/orgUserRegister";
private static final String MAIN_REGISTER_RESULT = "apps/register/userRegisterResult";
private final AgreementsFacade agreementsFacade;
private final AgreementFormModelSupport agreementFormModelSupport;
private final PortalProperties portalProperties;
private final OrgRegisterFacade orgRegisterFacade;
@@ -30,9 +30,8 @@ public class OrgRegisterController {
model.addAttribute("registrationType", "corporate");
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
return MAIN_ORG_REGISTER;
}
@@ -105,8 +104,7 @@ public class OrgRegisterController {
model.addAttribute("registrationType", "corporate");
model.addAttribute("portalOrg", orgDTO);
model.addAttribute("error", errorMessage);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
}
}
@@ -1,5 +1,7 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestParam;
public class UserManController {
private final UserManFacade userManFacade;
private final AgreementTypeConfigService agreementTypeConfigService;
@GetMapping
public String userList(@PageableDefault(sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable, Model model) {
@@ -34,6 +37,10 @@ public class UserManController {
model.addAttribute("pendingUsers", pendingUsers);
model.addAttribute("page", users);
model.addAttribute("currentUserId", SecurityUtil.getPortalAuthenticatedUser().getId());
// 초대/초대 취소 팝업의 알림 수신 동의 체크박스 노출 여부.
// 약관 페이지에 배치되어 있어야(= 사용 중이고 미배치가 아니어야) 동의서 링크를 안내할 수 있다.
model.addAttribute("notificationConsentAvailable",
agreementTypeConfigService.isDisplayed(AgreementType.NOTIFICATION_CONSENT));
return "apps/users/userList";
}
@@ -1,6 +1,6 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
@@ -10,7 +10,6 @@ import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
@@ -22,21 +21,15 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -61,10 +54,9 @@ public class UserRegisterController {
private final AuthFacade authFacade;
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final AgreementsFacade agreementsFacade;
private final AgreementFormModelSupport agreementFormModelSupport;
private final PortalProperties portalProperties;
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
private final PortalUserAuthService portalUserAuthService;
@@ -102,9 +94,8 @@ public class UserRegisterController {
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
return MAIN_USER_REGISTER;
@@ -368,9 +359,8 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("invitationCode", invitation.get().getToken());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
@@ -399,9 +389,8 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
@@ -443,9 +432,8 @@ public class UserRegisterController {
model.addAttribute("error", errorMessage);
model.addAttribute("agreement", agreement);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
model.addAttribute("msgType", "sms");
}
@@ -11,8 +11,6 @@ import com.eactive.apim.portal.apps.user.service.PortalOrgService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
@@ -20,26 +18,14 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final ApprovalService approvalService;
private final MessageHandlerService messageHandlerService;
private final EncryptionUtil encryptionUtil;
@Override
@Transactional
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(newUser);
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendActivationEmail(newUser);
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
}
private void sendActivationEmail(PortalUser newUser) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(newUser.getUserName());
recipient.setUserId(newUser.getEmailAddr());
recipient.setPhone(newUser.getMobileNumber());
HashMap<String, Object> params = new HashMap<>();
String tokenValue = newUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + newUser.getId();
try {
String encToken = encryptionUtil.encrypt(tokenValue);
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new SystemException("암호화 모듈 오류");
}
}
// 기존 사용자를 법인 사용자로 전환하는 메서드
private ValidationResponse convertExistingUserToCorporate(
PortalUser existingUser,
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.facade;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.service.AuthNumberGenerator;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
@@ -11,15 +10,11 @@ import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Optional;
@Service
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
private final PasswordValidator passwordValidator;
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final MessageHandlerService messageHandlerService;
private final EncryptionUtil encryptionUtil;
private final AuthNumberGenerator authNumberGenerator;
@Override
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendEmailActivation(newUser);
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
}
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return false;
}
private void sendEmailActivation(PortalUser registeredUser) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(registeredUser.getUserName());
recipient.setUserId(registeredUser.getEmailAddr());
recipient.setPhone(registeredUser.getMobileNumber());
// 25.10.01 - 이메일 링크 방식으로 접근 불가이기에 SMS 인증방식과 동일하게 대체
HashMap<String, Object> params = new HashMap<>();
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
params.put("token", tokenValue);
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
// try {
// String encToken = encryptionUtil.encrypt(tokenValue);
// params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
// messageHandlerService.publishEvent(UserEmailActivationEvent.KEY, recipient, params);
// } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
// throw new SystemException("암호화 모듈 오류");
// }
}
@Override
@Transactional
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
@@ -16,23 +16,14 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@@ -16,6 +16,7 @@ import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.djb.footer.RelatedSite;
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
import com.eactive.apim.portal.djb.guide.GuideProperty;
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@ControllerAdvice
@@ -51,6 +52,9 @@ public class GlobalControllerAdvice {
@Autowired
private Environment environment;
@Autowired
private PasswordCryptoProperties passwordCryptoProperties;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -125,6 +129,25 @@ public class GlobalControllerAdvice {
return passwordPolicyProperties.isKeyboardSequenceBlocked();
}
/**
* 비밀번호 전송암호화 설정(yml {@code portal.security.password-encrypt.*}).
* head 의 {@code window.__PASSWORD_CRYPTO__} 로 내려가 password-crypto.js 가 읽는다.
*
* <ul>
* <li>{@code enabled} - 꺼져 있으면 화면은 아무것도 하지 않고 평문 전송한다.</li>
* <li>{@code policy} - 평문 비밀번호 처리 정책. 화면은 경고 팝업 노출 여부·문구를 여기서 정한다.</li>
* <li>{@code softwareFallback} - {@code crypto.subtle} 이 없을 때 forge 번들을 내려받아 쓸지.</li>
* </ul>
*/
@ModelAttribute("passwordCrypto")
public Map<String, Object> passwordCrypto() {
Map<String, Object> config = new java.util.LinkedHashMap<>();
config.put("enabled", passwordCryptoProperties.isEnabled());
config.put("policy", passwordCryptoProperties.getPlaintextPolicy().name());
config.put("softwareFallback", passwordCryptoProperties.isSoftwareFallback());
return config;
}
/**
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
*/
@@ -1,8 +1,7 @@
package com.eactive.apim.portal.common.exception;
import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
@@ -36,23 +35,27 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@RequiredArgsConstructor
public class PortalGlobalExceptionHandler {
private static final String REDIRECT_PREFIX = "redirect:";
private static final String REDIRECT_HOME = REDIRECT_PREFIX + "/";
private static final String REDIRECT_LOGIN_AUTH = REDIRECT_PREFIX + "/login?reason=auth";
private final Logger log = LoggerFactory.getLogger(getClass());
private final PortalProperties portalProperties;
private final Environment environment;
@ExceptionHandler(value = NotFoundException.class)
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
return new ModelAndView("redirect:/");
return new ModelAndView(REDIRECT_HOME);
}
@ExceptionHandler(value = MethodArgumentTypeMismatchException.class)
public ModelAndView handleMethodArgumentTypeMismatchException(HttpServletRequest request, MethodArgumentTypeMismatchException ex) {
return new ModelAndView("redirect:/");
return new ModelAndView(REDIRECT_HOME);
}
@ExceptionHandler(value = UserNotLoginException.class)
public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) {
return new ModelAndView("redirect:/login?reason=auth");
return new ModelAndView(REDIRECT_LOGIN_AUTH);
}
@ExceptionHandler(value = AccessDeniedException.class)
@@ -61,7 +64,7 @@ public class PortalGlobalExceptionHandler {
if (!SecurityUtil.isAuthenticated()) {
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
savePostLoginRedirect(request);
return new ModelAndView("redirect:/login?reason=auth");
return new ModelAndView(REDIRECT_LOGIN_AUTH);
}
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
ModelAndView modelAndView = new ModelAndView("error");
@@ -122,9 +125,40 @@ public class PortalGlobalExceptionHandler {
|| path.startsWith("/favicon"));
}
/**
* 리다이렉트/뷰 이름으로 허용할 문자. 스킴 구분자(:)·중괄호·달러를 막아
* 외부 URL 이나 Thymeleaf 표현식이 뷰 이름으로 흘러드는 것을 차단한다.
*/
private static final Pattern SAFE_REDIRECT_TARGET = Pattern.compile("[A-Za-z0-9._/?=&%-]+");
@ExceptionHandler(value = PortalRedirectException.class)
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
return new ModelAndView(ex.getMessage());
// PortalRedirectException 은 super(message) 를 호출하지 않으므로 getMessage() 는 항상 null 이다.
// 이전 구현은 그 null 을 뷰 이름으로 넘겨(= 뷰 미지정) 요청 URL 기준으로 뷰가 추론되게 만들었다.
// 실제 대상은 redirectPage 필드다.
String target = ex.getRedirectPage();
if (!isSafeRedirectTarget(target)) {
log.warn("허용되지 않은 리다이렉트 대상 - uri={}, target={}", request.getRequestURI(), target);
return new ModelAndView(REDIRECT_HOME);
}
return new ModelAndView(target);
}
/** 내부 경로/뷰 이름만 허용(외부 URL·프로토콜 상대 URL·표현식 문자 차단) */
private boolean isSafeRedirectTarget(String target) {
if (target == null) {
return false;
}
String path = target;
if (path.startsWith(REDIRECT_PREFIX)) {
path = path.substring(REDIRECT_PREFIX.length());
} else if (path.startsWith("forward:")) {
path = path.substring("forward:".length());
}
if (path.isEmpty() || path.startsWith("//")) { // //evil.example 형태의 외부 리다이렉트 차단
return false;
}
return SAFE_REDIRECT_TARGET.matcher(path).matches();
}
@ExceptionHandler(value = {IllegalArgumentException.class})
@@ -140,7 +174,7 @@ public class PortalGlobalExceptionHandler {
public ModelAndView handleHttpRequestMethodNotSupportedException(HttpServletRequest request, HttpRequestMethodNotSupportedException ex) {
log.error(ex.getMessage());
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("redirect:/");
modelAndView.setViewName(REDIRECT_HOME);
return modelAndView;
}
@@ -159,10 +193,9 @@ public class PortalGlobalExceptionHandler {
*/
@ExceptionHandler(value = Exception.class)
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
String requestParams = request.getParameterMap().entrySet()
.stream()
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
.collect(Collectors.joining(", "));
// 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
// 이 시점의 파라미터는 이미 복호화된 평문이다.
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
@@ -184,7 +217,7 @@ public class PortalGlobalExceptionHandler {
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
ModelAndView modelAndView = new ModelAndView();
redirectAttributes.addFlashAttribute("error", ex.getMessage());
modelAndView.setViewName("redirect:" + request.getRequestURI());
modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
return modelAndView;
}
@@ -195,7 +228,7 @@ public class PortalGlobalExceptionHandler {
String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다.";
redirectAttributes.addFlashAttribute("error", errorMessage);
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("redirect:" + request.getRequestURI());
modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
return modelAndView;
}
}
@@ -0,0 +1,90 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* 멀티파트(파일 업로드 동반) 요청 전용 복호화 래퍼.
*
* <p><b>배경</b> — {@link DecryptingRequestWrapper} 는 {@code getParameter()} 호출 시
* {@code super.getParameter()} 위임 결과를 복호화하는 방식이다. 그런데 Spring 의
* {@link org.springframework.web.multipart.support.MultipartFilter} 가 만드는
* {@code MultipartHttpServletRequest} 는 자신이 직접 파싱한 폼 필드(파일이 아닌 text part) 값을
* {@code getParameter()} 에서 곧바로 반환하고 {@code super.getParameter()} 로 위임하지 않는다.
* 그 결과 {@code DecryptingRequestWrapper} 가 멀티파트 파싱 "이전"(안쪽)에 씌워지면, 멀티파트 안의
* 비밀번호 파라미터는 복호화되지 않은 봉투(ENC1) 문자열 그대로 컨트롤러까지 전달된다 — 법인가입처럼
* 파일 첨부(사업자등록증)와 비밀번호가 같은 폼에 있는 경우 재현된다.
*
* <p><b>해결</b> — {@link PasswordDecryptFilter} 를 MultipartFilter "이후"에 실행되도록 순서를
* 옮기면(그리고 Lucy XSS 필터보다는 여전히 앞서도록), 이 필터가 감싸는 대상이 이미
* {@code MultipartHttpServletRequest} 다. 이때는 이 클래스로 감싸 {@link MultipartHttpServletRequest}
* 인터페이스 자체를 구현하고 파일 관련 메서드는 원본 멀티파트 요청에 위임한다 — Spring 의
* {@code MultipartFile} 바인딩({@code WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class)})이
* 이 래퍼를 그대로 인식하도록 하기 위함이다. 파라미터(텍스트 필드) 복호화 로직은 부모
* {@link DecryptingRequestWrapper} 그대로 재사용한다.
*/
public class DecryptingMultipartRequestWrapper extends DecryptingRequestWrapper
implements MultipartHttpServletRequest {
private final MultipartHttpServletRequest multipartRequest;
public DecryptingMultipartRequestWrapper(MultipartHttpServletRequest request,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore,
PasswordCryptoProperties properties) {
super(request, codec, keyStore, properties);
this.multipartRequest = request;
}
@Override
public Iterator<String> getFileNames() {
return multipartRequest.getFileNames();
}
@Override
public MultipartFile getFile(String name) {
return multipartRequest.getFile(name);
}
@Override
public List<MultipartFile> getFiles(String name) {
return multipartRequest.getFiles(name);
}
@Override
public Map<String, MultipartFile> getFileMap() {
return multipartRequest.getFileMap();
}
@Override
public MultiValueMap<String, MultipartFile> getMultiFileMap() {
return multipartRequest.getMultiFileMap();
}
@Override
public String getMultipartContentType(String paramOrFileName) {
return multipartRequest.getMultipartContentType(paramOrFileName);
}
@Override
public HttpMethod getRequestMethod() {
return multipartRequest.getRequestMethod();
}
@Override
public HttpHeaders getRequestHeaders() {
return multipartRequest.getRequestHeaders();
}
@Override
public HttpHeaders getMultipartHeaders(String paramOrFileName) {
return multipartRequest.getMultipartHeaders(paramOrFileName);
}
}
@@ -0,0 +1,181 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.security.PrivateKey;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* 봉투(ENC1) 형식으로 들어온 파라미터 값을 평문으로 되돌리는 요청 래퍼.
*
* <p><b>지연 복호화가 필수다.</b> 생성자에서 {@code getParameterMap()} 을 부르면 컨테이너가 요청 본문을
* 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
*
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 3})보다 <b>앞</b>({@code MIN_VALUE + 2})에서 이 래퍼가
* 씌워지므로, 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든
* 비밀번호의 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
*
* <p><b>멀티파트(파일 업로드) 요청 주의</b> — {@code getParameter()} 는 {@code super.getParameter()} 위임
* 결과를 복호화하는 방식이라, 이 래퍼가 감싸는 시점에 {@code super} 가 이미 멀티파트 파싱이 끝난
* {@code MultipartHttpServletRequest} 여야만 폼 필드(파일이 아닌 text part)를 볼 수 있다 — Spring 의
* 멀티파트 파라미터 맵은 자신이 파싱한 값을 곧바로 반환하고 감싸고 있는 원본 요청으로 위임하지 않기
* 때문이다. 그래서 {@link PasswordDecryptFilter} 는 MultipartFilter({@code order = MIN_VALUE + 1})
* "이후"에 실행되도록 등록돼 있고, 감싸는 대상이 {@code MultipartHttpServletRequest} 면 이 클래스 대신
* {@link DecryptingMultipartRequestWrapper}(이 클래스를 상속하며 멀티파트 인터페이스도 구현)를 쓴다.</p>
*/
@Slf4j
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
private final PasswordCryptoProperties properties;
/** 봉투 원문 → 평문. 같은 값이 여러 번 조회돼도 RSA 연산은 한 번만 한다. */
private final Map<String, String> decrypted = new HashMap<>();
/** 요청 처리 후 폐기할 keyId(1회용 키 모드). */
private final Set<String> usedKeyIds = new LinkedHashSet<>();
private Map<String, String[]> parameterMapCache;
/**
* 평문 비밀번호 경고를 요청당 한 번만 남기기 위한 표시.
* 파라미터는 컨트롤러·검증기에서 여러 번 조회되고, 같은 폼에 비밀번호 계열 필드가 둘 이상인
* 경우도 흔해서(비밀번호/비밀번호확인) 그대로 두면 한 번의 제출이 로그 여러 줄을 만든다.
*/
private boolean plaintextWarned;
public DecryptingRequestWrapper(HttpServletRequest request,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore,
PasswordCryptoProperties properties) {
super(request);
this.codec = codec;
this.keyStore = keyStore;
this.properties = properties;
}
@Override
public String getParameter(String name) {
return convert(name, super.getParameter(name));
}
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values == null) {
return null;
}
String[] converted = new String[values.length];
for (int i = 0; i < values.length; i++) {
converted[i] = convert(name, values[i]);
}
return converted;
}
@Override
public Map<String, String[]> getParameterMap() {
if (parameterMapCache == null) {
Map<String, String[]> source = super.getParameterMap();
Map<String, String[]> result = new HashMap<>(Math.max(16, source.size() * 2));
for (Map.Entry<String, String[]> entry : source.entrySet()) {
String name = entry.getKey();
String[] values = entry.getValue();
String[] converted = new String[values.length];
for (int i = 0; i < values.length; i++) {
converted[i] = convert(name, values[i]);
}
result.put(name, converted);
}
parameterMapCache = Collections.unmodifiableMap(result);
}
return parameterMapCache;
}
/** 요청 처리가 끝난 뒤 필터가 호출한다. 1회용 키를 폐기해 재전송을 막는다. */
void consumeUsedKeys() {
for (String keyId : usedKeyIds) {
keyStore.consume(keyId, (HttpServletRequest) getRequest());
}
usedKeyIds.clear();
}
private String convert(String name, String value) {
if (value == null || value.isEmpty()) {
return value;
}
if (!codec.isEnvelope(value)) {
return handlePlaintext(name, value);
}
String cached = decrypted.get(value);
if (cached != null) {
return cached;
}
String keyId = codec.keyIdOf(value);
if (keyId == null) {
log.warn("봉투 형식 오류 - uri={}, param={}", getRequestURI(), name);
return failed(value);
}
PrivateKey privateKey = keyStore.resolve(keyId, (HttpServletRequest) getRequest());
if (privateKey == null) {
log.warn("전송암호화 키를 찾을 수 없음(만료·인스턴스 불일치) - uri={}, param={}, keyId={}",
getRequestURI(), name, keyId);
return failed(value);
}
try {
String plain = codec.decrypt(value, privateKey);
decrypted.put(value, plain);
usedKeyIds.add(keyId);
return plain;
} catch (PasswordDecryptException e) {
log.warn("전송암호화 복호화 실패 - uri={}, param={}, keyId={}, reason={}",
getRequestURI(), name, keyId, e.getMessage());
usedKeyIds.add(keyId);
return failed(value);
}
}
/**
* 봉투가 씌워지지 않은 파라미터 처리. 비밀번호 계열이 아니면 그대로 통과시킨다.
*
* <p>{@code ENFORCE} 에서 예외를 던지면 로그인 경로가 500 이 되므로 빈 값으로 바꿔
* 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.</p>
*/
private String handlePlaintext(String name, String value) {
PasswordCryptoProperties.PlaintextPolicy policy = properties.getPlaintextPolicy();
if (policy == PasswordCryptoProperties.PlaintextPolicy.NONE
|| !PasswordParamNames.isPasswordLike(name)) {
return value;
}
boolean reject = policy == PasswordCryptoProperties.PlaintextPolicy.ENFORCE;
if (!plaintextWarned) {
plaintextWarned = true;
log.warn("암호화되지 않은 비밀번호 파라미터 {} - policy={}, transport={}, uri={}, param={}",
reject ? "거부" : "허용", policy,
RequestTransport.isSecure((HttpServletRequest) getRequest()) ? "https" : "http",
getRequestURI(), name);
}
return reject ? "" : value;
}
/**
* 복호화 실패 시의 값. 봉투 문자열을 그대로 흘려보내면 비밀번호 정책 검증 등이 엉뚱하게 통과할 수 있어
* 빈 값으로 바꾼다. 결과적으로 사용자에게는 일반적인 입력값 오류로 보인다.
*/
private String failed(String rawValue) {
decrypted.put(rawValue, "");
return "";
}
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 클라이언트에 내려줄 공개키 1건. {@code PasswordKeyController} 응답 본문이기도 하다.
*/
@Data
@AllArgsConstructor
public class IssuedKey {
/** 봉투(ENC1)의 두 번째 세그먼트로 되돌아오는 키 식별자. */
private final String keyId;
/** X.509 SubjectPublicKeyInfo(SPKI) DER 을 표준 base64 로 인코딩한 값. */
private final String publicKey;
/** 남은 수명(초). 클라이언트 캐시 판단용. */
private final int expiresIn;
}
@@ -0,0 +1,76 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
*
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.</p>
*
* <p><b>이 기능은 XSS 방어도, MITM 방어도 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있고,
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. "전송 페이로드 평문 노출" 점검 지적에
* 대한 대응 범위로만 이해할 것.</p>
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal.security.password-encrypt")
public class PasswordCryptoProperties {
/**
* RSA 개인키 보관 범위.
*
* <ul>
* <li>{@code REQUEST} - 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기. 재전송 공격 차단이 가장 강하다.</li>
* <li>{@code SESSION} - 세션 단위로 키를 보관. 세션 복제 환경에 유리하다.</li>
* <li>{@code SERVER} - 서버 고정 키쌍 + TTL 로테이션. 무상태라 가장 단순하지만 재전송 방지 수단이 없다.</li>
* </ul>
*/
public enum KeyScope {
REQUEST, SESSION, SERVER
}
/**
* 봉투(ENC1)가 적용되지 않은 <b>평문 비밀번호 파라미터</b>를 서버가 어떻게 다룰지.
* 전송 구간(HTTP/HTTPS) 자체를 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
*
* <ul>
* <li>{@code NONE} - 무동작. 평문을 그대로 받는다.</li>
* <li>{@code PERMISSIVE} - 평문을 받되 서버 로그에 경고를 남기고, 화면에도 경고 팝업을 띄운다.</li>
* <li>{@code ENFORCE} - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).</li>
* </ul>
*
* <p>{@code ENFORCE} 는 전송 구간이 HTTP 여도 안전하게 켤 수 있다. {@link #softwareFallback} 이 켜져 있으면
* {@code crypto.subtle} 을 못 쓰는 환경에서도 클라이언트가 봉투를 만들기 때문이다. 다만 JS 를 끈
* 브라우저는 로그인하지 못하므로 {@code PERMISSIVE} 로 운영해 경고 로그를 지켜본 뒤 승격한다.</p>
*/
public enum PlaintextPolicy {
NONE, PERMISSIVE, ENFORCE
}
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
private boolean enabled = false;
/** 키 보관 범위. */
private KeyScope keyScope = KeyScope.REQUEST;
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
private int keyTtlSeconds = 300;
/** 평문 비밀번호 파라미터 처리 정책. */
private PlaintextPolicy plaintextPolicy = PlaintextPolicy.NONE;
/**
* {@code crypto.subtle} 을 쓸 수 없는 환경(원격 오리진 HTTP = 비 secure context)에서
* 순수 JS 구현(forge)으로 봉투를 만들지 여부.
*
* <p>forge 번들은 {@code crypto.subtle} 이 없을 때만 동적으로 내려가므로 HTTPS 운영 환경에서는
* 전송 바이트가 0 이다. 즉 이 값은 사실상 킬스위치다. 끄면 HTTP 개발환경은 평문으로 폴백한다
* ({@link #plaintextPolicy} 가 {@code ENFORCE} 면 로그인 불가).</p>
*/
private boolean softwareFallback = true;
/** RSA 키 길이. */
private int rsaKeySize = 2048;
}
@@ -0,0 +1,18 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 봉투 복호화 실패. 화면으로 스택을 올리지 않고 필터에서 흡수한다.
* 메시지에 평문이나 키 자료를 담지 않는다.
*/
public class PasswordDecryptException extends RuntimeException {
private static final long serialVersionUID = 1L;
public PasswordDecryptException(String message) {
super(message);
}
public PasswordDecryptException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
*
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE + 2} 로 등록해
* MultipartFilter({@code MIN_VALUE + 1}) "이후", Lucy XSS 필터({@code MIN_VALUE + 3}) "이전"에
* 실행된다. 자세한 이유는 {@link DecryptingRequestWrapper} / {@link DecryptingMultipartRequestWrapper}
* 주석 참고 — 멀티파트(파일 업로드 동반) 요청은 MultipartFilter 가 만든
* {@code MultipartHttpServletRequest} 가 폼 필드를 자체 파싱해 반환하므로, 이 필터가 그보다
* 먼저 실행되면 비밀번호 파라미터가 복호화되지 않은 채로 컨트롤러까지 전달된다(법인가입처럼
* 파일 첨부와 비밀번호가 같은 폼에 있는 경우 재현).</p>
*
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</p>
*
* <p><b>{@code /internal/**} 은 대상에서 제외한다.</b> playwright 전용 테스트 정리 API
* ({@code TestCleanupInternalController} 등)는 공유 토큰({@code X-Internal-Token}) + IP 허용목록으로
* 이미 인증되는 서버-to-서버 호출이라 브라우저 암호화 대상이 아니다({@code MenuAccessInterceptor} 가
* 같은 이유로 {@code /internal/**} 을 제외하는 것과 동일 패턴). 이 경로를 필터에 그대로 태우면, 도구가
* 보내는 평문 {@code password} 파라미터가 {@code plaintext-policy=ENFORCE} 설정에 걸려 빈 문자열로
* 치환되고 내부 API 는 "필수값 누락"으로 거부한다(예: {@code /internal/test-cleanup/password}).</p>
*/
@Component
public class PasswordDecryptFilter implements Filter {
private static final String INTERNAL_API_PREFIX = "/internal/";
private final PasswordCryptoProperties properties;
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
public PasswordDecryptFilter(PasswordCryptoProperties properties,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore) {
this.properties = properties;
this.codec = codec;
this.keyStore = keyStore;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!properties.isEnabled() || !(request instanceof HttpServletRequest)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (!"POST".equalsIgnoreCase(httpRequest.getMethod()) || isInternalApiRequest(httpRequest)) {
chain.doFilter(request, response);
return;
}
DecryptingRequestWrapper wrapper = httpRequest instanceof MultipartHttpServletRequest
? new DecryptingMultipartRequestWrapper((MultipartHttpServletRequest) httpRequest, codec, keyStore, properties)
: new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
try {
chain.doFilter(wrapper, response);
} finally {
wrapper.consumeUsedKeys();
}
}
private static boolean isInternalApiRequest(HttpServletRequest request) {
String path = request.getRequestURI().substring(request.getContextPath().length());
return path.startsWith(INTERNAL_API_PREFIX);
}
}
@@ -0,0 +1,104 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.PrivateKey;
import java.security.spec.MGF1ParameterSpec;
import java.util.Base64;
/**
* 클라이언트가 만든 봉투(envelope) 문자열의 파싱·복호화.
*
* <pre>
* ENC1.&lt;keyId&gt;.&lt;b64url(RSA-OAEP(AES키))&gt;.&lt;b64url(iv 12B)&gt;.&lt;b64url(AES-GCM 암호문+태그)&gt;
* </pre>
*
* <p>세그먼트는 base64<b>url</b>(패딩 없음)이라 Lucy XSS 이스케이프와 urlencode 를 모두 통과해도
* 값이 변형되지 않는다.</p>
*
* <p>RSA-OAEP 는 반드시 MGF1 해시까지 SHA-256 으로 지정해야 한다. SunJCE 는
* {@code OAEPWithSHA-256AndMGF1Padding} 만 지정하면 MGF1 에 SHA-1 을 쓰는데,
* 브라우저 Web Crypto 의 {@code RSA-OAEP + SHA-256} 은 MGF1 도 SHA-256 이라 그대로 두면
* 복호화가 실패한다.</p>
*/
@Component
public class PasswordEnvelopeCodec {
public static final String PREFIX = "ENC1.";
/** AES-GCM 인증 태그 길이(비트). Web Crypto 기본값과 동일. */
private static final int GCM_TAG_BITS = 128;
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
/** 값이 봉투 형식인지. 파싱 비용 없이 접두사만 본다. */
public boolean isEnvelope(String value) {
return value != null && value.startsWith(PREFIX);
}
/** 봉투에서 keyId 만 추출. 형식이 어긋나면 {@code null}. */
public String keyIdOf(String value) {
String[] parts = split(value);
return parts == null ? null : parts[1];
}
/**
* RSA 개인키로 AES 키를 풀고 AES-GCM 으로 본문을 복호화한다.
*
* @throws PasswordDecryptException 형식 오류·키 불일치·태그 검증 실패
*/
public String decrypt(String value, PrivateKey privateKey) {
String[] parts = split(value);
if (parts == null) {
throw new PasswordDecryptException("봉투 형식이 올바르지 않다");
}
try {
Key aesKey = unwrapAesKey(URL_DECODER.decode(parts[2]), privateKey);
byte[] iv = URL_DECODER.decode(parts[3]);
byte[] cipherText = URL_DECODER.decode(parts[4]);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
} catch (PasswordDecryptException e) {
throw e;
} catch (Exception e) {
throw new PasswordDecryptException("봉투 복호화 실패", e);
}
}
private Key unwrapAesKey(byte[] wrapped, PrivateKey privateKey) {
try {
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsa.init(Cipher.DECRYPT_MODE, privateKey, new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
return new SecretKeySpec(rsa.doFinal(wrapped), "AES");
} catch (Exception e) {
throw new PasswordDecryptException("AES 키 언랩 실패", e);
}
}
/** {@code ENC1.keyId.encKey.iv.cipher} 5개 세그먼트. 어긋나면 null. */
private String[] split(String value) {
if (!isEnvelope(value)) {
return null;
}
String[] parts = value.split("\\.", 5);
if (parts.length != 5) {
return null;
}
for (String part : parts) {
if (part.isEmpty()) {
return null;
}
}
return parts;
}
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.Map;
/**
* 비밀번호 전송암호화용 공개키 발급.
*
* <p>경로를 {@code /api/**} 아래에 둔 이유: {@code PortalConfigWebDispatcherServlet#addInterceptors}
* 가 {@code /api/**} 를 인터셉터(비밀번호 변경 강제·step-up 가드)에서 제외한다. 로그인 전 익명 상태에서
* 호출되므로 {@code /api/session/csrf} 와 같은 자리에 있어야 리다이렉트에 걸리지 않는다.</p>
*
* <p>GET 이라 CSRF 대상이 아니고, 공개키만 나가므로 인증도 요구하지 않는다.</p>
*/
@RestController
@RequestMapping("/api/security")
public class PasswordKeyController {
private final PasswordCryptoProperties properties;
private final PasswordKeyStore keyStore;
public PasswordKeyController(PasswordCryptoProperties properties, PasswordKeyStore keyStore) {
this.properties = properties;
this.keyStore = keyStore;
}
@GetMapping("/password-key.json")
public ResponseEntity<Map<String, Object>> issue(HttpServletRequest request) {
if (!properties.isEnabled()) {
// 클라이언트가 조용히 평문 폴백하도록 200 + enabled:false 로 답한다.
return noStore().body(Collections.<String, Object>singletonMap("enabled", Boolean.FALSE));
}
IssuedKey key = keyStore.issue(request);
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("enabled", Boolean.TRUE);
body.put("alg", "RSA-OAEP-256");
body.put("keyId", key.getKeyId());
body.put("publicKey", key.getPublicKey());
body.put("expiresIn", key.getExpiresIn());
// REQUEST 는 1회용이라 클라이언트가 캐시하면 안 된다. 나머지는 만료까지 재사용해
// 비밀번호 검증용 ajax 가 호출마다 RSA 키쌍을 만들게 하지 않는다.
body.put("keyScope", properties.getKeyScope().name());
return noStore().body(body);
}
private ResponseEntity.BodyBuilder noStore() {
return ResponseEntity.ok().cacheControl(CacheControl.noStore());
}
}
@@ -0,0 +1,52 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.UUID;
/**
* RSA 키쌍 생성/인코딩 헬퍼. 키 보관소 구현들이 공유한다.
*/
final class PasswordKeyPairs {
private PasswordKeyPairs() {
}
static KeyPair generate(int keySize) {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(keySize);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("RSA 키쌍 생성 실패", e);
}
}
static String newKeyId() {
return UUID.randomUUID().toString().replace("-", "");
}
/** 브라우저 {@code crypto.subtle.importKey('spki', ...)} 가 그대로 먹는 형식. */
static String toSpkiBase64(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
/**
* PKCS#8 바이트 → PrivateKey. 세션 복제(직렬화) 환경을 고려해
* {@link SessionScopedPasswordKeyStore} 는 키 객체 대신 바이트를 보관한다.
*/
static PrivateKey toPrivateKey(byte[] pkcs8) {
try {
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("RSA 개인키 복원 실패", e);
}
}
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import javax.servlet.http.HttpServletRequest;
import java.security.PrivateKey;
/**
* 비밀번호 전송암호화용 RSA 키쌍 보관소. 구현체는 {@link PasswordCryptoProperties.KeyScope} 별로 존재하며
* {@link PasswordKeyStoreRouter} 가 설정값에 따라 위임한다.
*/
public interface PasswordKeyStore {
/** 새 공개키를 발급한다(구현에 따라 기존 키 재사용). */
IssuedKey issue(HttpServletRequest request);
/** keyId 에 대응하는 개인키. 없거나 만료됐으면 {@code null}. */
PrivateKey resolve(String keyId, HttpServletRequest request);
/** 요청 처리가 끝난 뒤 호출. 1회용 키를 폐기하는 구현에서만 의미가 있다. */
void consume(String keyId, HttpServletRequest request);
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.PrivateKey;
/**
* {@code portal.security.password-encrypt.key-scope} 설정값에 따라 실제 보관소로 위임한다.
* 설정은 yml 이므로 기동 시점에 고정된다(런타임 변경 시 재기동 필요).
*/
@Primary
@Component
public class PasswordKeyStoreRouter implements PasswordKeyStore {
private final PasswordCryptoProperties properties;
private final RequestScopedPasswordKeyStore requestScoped;
private final SessionScopedPasswordKeyStore sessionScoped;
private final ServerScopedPasswordKeyStore serverScoped;
public PasswordKeyStoreRouter(PasswordCryptoProperties properties,
RequestScopedPasswordKeyStore requestScoped,
SessionScopedPasswordKeyStore sessionScoped,
ServerScopedPasswordKeyStore serverScoped) {
this.properties = properties;
this.requestScoped = requestScoped;
this.sessionScoped = sessionScoped;
this.serverScoped = serverScoped;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
return delegate().issue(request);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
return delegate().resolve(keyId, request);
}
@Override
public void consume(String keyId, HttpServletRequest request) {
delegate().consume(keyId, request);
}
private PasswordKeyStore delegate() {
PasswordCryptoProperties.KeyScope scope = properties.getKeyScope();
if (scope == PasswordCryptoProperties.KeyScope.SESSION) {
return sessionScoped;
}
if (scope == PasswordCryptoProperties.KeyScope.SERVER) {
return serverScoped;
}
return requestScoped;
}
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 비밀번호 계열 파라미터명 판별. {@code plaintext-policy} 가 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
*
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
* 향후 축약형이 생길 것을 대비해 {@code passwd}/{@code pwd} 도 함께 본다.</p>
*/
final class PasswordParamNames {
private PasswordParamNames() {
}
static boolean isPasswordLike(String name) {
if (name == null) {
return false;
}
String lower = name.toLowerCase();
return lower.contains("password") || lower.contains("passwd") || lower.contains("pwd");
}
}
@@ -0,0 +1,90 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기하는 보관소({@code key-scope: REQUEST}).
*
* <p>같은 keyId 로 두 번 복호화할 수 없으므로 재전송(replay) 공격이 차단된다. 개인키가 인스턴스
* 로컬 메모리에만 있으므로 WebLogic 다중 인스턴스에서는 스티키 세션이 전제다(현재 CSRF·단일세션
* 강제가 이미 세션 고정을 전제한다).</p>
*/
@Slf4j
@Component
public class RequestScopedPasswordKeyStore implements PasswordKeyStore {
/** 메모리 폭주 방지 상한. 초과 시 만료 스윕 후에도 남으면 발급을 거절하지 않고 가장 오래된 것부터 버린다. */
private static final int MAX_ENTRIES = 20_000;
private final PasswordCryptoProperties properties;
private final Map<String, Entry> entries = new ConcurrentHashMap<>();
public RequestScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
sweep();
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
String keyId = PasswordKeyPairs.newKeyId();
int ttl = properties.getKeyTtlSeconds();
entries.put(keyId, new Entry(keyPair.getPrivate(), System.currentTimeMillis() + ttl * 1000L));
return new IssuedKey(keyId, PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()), ttl);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
Entry entry = entries.get(keyId);
if (entry == null) {
return null;
}
if (entry.expiresAt < System.currentTimeMillis()) {
entries.remove(keyId);
return null;
}
return entry.privateKey;
}
@Override
public void consume(String keyId, HttpServletRequest request) {
entries.remove(keyId);
}
/** 만료 항목 정리. 발급 시점에만 돌리므로 별도 스케줄러가 필요 없다. */
private void sweep() {
long now = System.currentTimeMillis();
Iterator<Map.Entry<String, Entry>> it = entries.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().expiresAt < now) {
it.remove();
}
}
if (entries.size() >= MAX_ENTRIES) {
log.warn("비밀번호 전송암호화 키 보관소 상한 초과 - size={}, 오래된 항목을 버린다", entries.size());
Iterator<Map.Entry<String, Entry>> overflow = entries.entrySet().iterator();
while (overflow.hasNext() && entries.size() >= MAX_ENTRIES) {
overflow.next();
overflow.remove();
}
}
}
private static final class Entry {
private final PrivateKey privateKey;
private final long expiresAt;
private Entry(PrivateKey privateKey, long expiresAt) {
this.privateKey = privateKey;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,31 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import javax.servlet.http.HttpServletRequest;
/**
* 요청이 TLS 구간으로 들어왔는지 판정한다. 경고 로그 문구를 가르는 용도다.
*
* <p>{@code request.isSecure()} 만 봐서는 안 된다. {@code PasswordDecryptFilter} 는 Lucy XSS 필터보다
* 먼저 돌아야 해서 order 가 {@code Integer.MIN_VALUE} 인데, {@code ForwardedHeaderFilter}
* ({@code PortalConfigForwardedHeader}) 도 {@code Ordered.HIGHEST_PRECEDENCE} = 같은 값이라
* 둘의 상대 순서가 보장되지 않는다. 즉 이 시점의 {@code isSecure()} 는 {@code X-Forwarded-Proto}
* 교정 <b>전</b> 값일 수 있다.</p>
*
* <p>그래서 헤더를 직접 본다. 앞단 프록시가 신뢰 경계 안이라는 전제는 {@code ForwardedHeaderFilter} 와 같다.</p>
*/
final class RequestTransport {
private RequestTransport() {
}
static boolean isSecure(HttpServletRequest request) {
String proto = request.getHeader("X-Forwarded-Proto");
if (proto != null && !proto.isEmpty()) {
// 프록시가 여러 단이면 "https, http" 처럼 쌓인다. 클라이언트에 가장 가까운 첫 값이 기준이다.
int comma = proto.indexOf(',');
String first = (comma >= 0 ? proto.substring(0, comma) : proto).trim();
return "https".equalsIgnoreCase(first);
}
return request.isSecure();
}
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.KeyPair;
import java.security.PrivateKey;
/**
* 서버 고정 키쌍 + TTL 로테이션 구현({@code key-scope: SERVER}).
*
* <p>직전 키를 1개 유예 보관해, 로테이션 순간에 이미 공개키를 받아 간 폼이 제출돼도 복호화된다.
* 재전송 방지 수단은 없으므로(같은 봉투를 여러 번 보내도 복호화됨) 인증 실패횟수 제한·계정 잠금에
* 의존한다. 무상태라 다중 인스턴스에서도 각자 동작하지만, 인스턴스마다 키가 달라
* 공개키 발급과 폼 제출이 같은 인스턴스로 가야 한다.</p>
*/
@Component
public class ServerScopedPasswordKeyStore implements PasswordKeyStore {
private final PasswordCryptoProperties properties;
private volatile Holder current;
private volatile Holder previous;
public ServerScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
Holder holder = currentHolder();
int remaining = (int) Math.max(1, (holder.expiresAt - System.currentTimeMillis()) / 1000L);
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
Holder holder = current;
if (holder != null && holder.keyId.equals(keyId)) {
return holder.privateKey;
}
Holder old = previous;
if (old != null && old.keyId.equals(keyId)) {
return old.privateKey;
}
return null;
}
@Override
public void consume(String keyId, HttpServletRequest request) {
// 고정 키라 폐기하지 않는다.
}
private synchronized Holder currentHolder() {
long now = System.currentTimeMillis();
if (current == null || current.expiresAt < now) {
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
previous = current;
current = new Holder(
PasswordKeyPairs.newKeyId(),
keyPair.getPrivate(),
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
now + properties.getKeyTtlSeconds() * 1000L);
}
return current;
}
private static final class Holder {
private final String keyId;
private final PrivateKey privateKey;
private final String publicKeySpki;
private final long expiresAt;
private Holder(String keyId, PrivateKey privateKey, String publicKeySpki, long expiresAt) {
this.keyId = keyId;
this.privateKey = privateKey;
this.publicKeySpki = publicKeySpki;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,84 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.Serializable;
import java.security.KeyPair;
import java.security.PrivateKey;
/**
* 세션 단위로 키쌍을 보관하는 구현({@code key-scope: SESSION}).
*
* <p>개인키를 객체가 아니라 PKCS#8 바이트로 들고 있어 세션 복제(직렬화)에도 안전하다.
* 같은 keyId 가 세션 수명 동안 재사용되므로 재전송 방지는 IV 랜덤성과 애플리케이션의
* 인증 실패횟수 제한에 의존한다.</p>
*/
@Component
public class SessionScopedPasswordKeyStore implements PasswordKeyStore {
private static final String SESSION_ATTR = "DJB_PWD_CRYPTO_KEY";
private final PasswordCryptoProperties properties;
public SessionScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
HttpSession session = request.getSession(true);
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
long now = System.currentTimeMillis();
if (holder == null || holder.expiresAt < now) {
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
holder = new Holder(
PasswordKeyPairs.newKeyId(),
keyPair.getPrivate().getEncoded(),
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
now + properties.getKeyTtlSeconds() * 1000L);
session.setAttribute(SESSION_ATTR, holder);
}
int remaining = (int) Math.max(1, (holder.expiresAt - now) / 1000L);
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
if (holder == null || !holder.keyId.equals(keyId)) {
return null;
}
if (holder.expiresAt < System.currentTimeMillis()) {
session.removeAttribute(SESSION_ATTR);
return null;
}
return PasswordKeyPairs.toPrivateKey(holder.privateKeyPkcs8);
}
@Override
public void consume(String keyId, HttpServletRequest request) {
// 세션 수명 동안 재사용한다.
}
private static final class Holder implements Serializable {
private static final long serialVersionUID = 1L;
private final String keyId;
private final byte[] privateKeyPkcs8;
private final String publicKeySpki;
private final long expiresAt;
private Holder(String keyId, byte[] privateKeyPkcs8, String publicKeySpki, long expiresAt) {
this.keyId = keyId;
this.privateKeyPkcs8 = privateKeyPkcs8;
this.publicKeySpki = publicKeySpki;
this.expiresAt = expiresAt;
}
}
}
@@ -1,18 +1,5 @@
package com.eactive.apim.portal.common.util;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component("encryptionUtil")
public class EncryptionUtil {
public static String generateNewPassword() {
@@ -53,40 +40,4 @@ public class EncryptionUtil {
return newpassword.toString();
}
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String secretKey; // Should be 16, 24, or 32 bytes long for AES-128, AES-192, or AES-256
private static final String ALGORITHM = "AES";
private SecretKeySpec createSecretKey() {
byte[] key = secretKey.getBytes(StandardCharsets.UTF_8);
return new SecretKeySpec(key, ALGORITHM);
}
public String encrypt(String value) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
if (value == null || value.isEmpty()) {
return value;
}
SecretKeySpec key = createSecretKey();
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encryptedBytes = cipher.doFinal(value.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
}
public String decrypt(String encrypted) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
if (encrypted == null || encrypted.isEmpty()) {
return encrypted;
}
SecretKeySpec key = createSecretKey();
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encrypted));
return new String(decryptedBytes);
}
}
@@ -172,6 +172,50 @@ public class StringMaskingUtil {
return value;
}
/** 요청 파라미터 로깅 시 값을 통째로 가릴 키. 부분일치(소문자)로 본다. */
private static final String[] SENSITIVE_PARAM_KEYWORDS = {
"password", "passwd", "pwd", "secret", "credential", "token"};
/**
* 요청 파라미터 맵을 로그용 문자열로 만든다. 비밀번호·시크릿·토큰 계열 키는 값을 {@code [****]} 로 가린다.
*
* <p>비밀번호 전송암호화가 켜져 있어도 이 시점의 파라미터는 이미 복호화된 평문이므로,
* 마스킹 없이 로깅하면 암호화 조치가 무의미해진다.</p>
*
* <pre>id=[user@a.com], password=[****]</pre>
*/
public static String maskParameterMap(java.util.Map<String, String[]> parameterMap) {
if (parameterMap == null || parameterMap.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(entry.getKey()).append('=');
if (isSensitiveParamName(entry.getKey())) {
sb.append("[****]");
} else {
sb.append(Arrays.toString(entry.getValue()));
}
}
return sb.toString();
}
private static boolean isSensitiveParamName(String name) {
if (!isValidString(name)) {
return false;
}
String lower = name.toLowerCase();
for (String keyword : SENSITIVE_PARAM_KEYWORDS) {
if (lower.contains(keyword)) {
return true;
}
}
return false;
}
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.common.validator;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.stereotype.Component;
import javax.validation.ConstraintValidator;
@@ -39,14 +40,8 @@ public class CellPhoneValidator implements ConstraintValidator<CellPhone, String
}
// 기존의 prefix 체크 로직 유지
return StringUtils.startsWith(cellPhone, "+821") ||
StringUtils.startsWith(cellPhone, "821") ||
StringUtils.startsWith(cellPhone, "010") ||
StringUtils.startsWith(cellPhone, "011") ||
StringUtils.startsWith(cellPhone, "016") ||
StringUtils.startsWith(cellPhone, "017") ||
StringUtils.startsWith(cellPhone, "018") ||
StringUtils.startsWith(cellPhone, "019");
return Strings.CS.startsWithAny(cellPhone,
"+821", "821", "010", "011", "016", "017", "018", "019");
}
private boolean isValidPartLength(String prefix, String middle, String last) {
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.FileUpload;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
@@ -18,6 +20,18 @@ import org.springframework.web.multipart.commons.CommonsMultipartResolver;
@RequiredArgsConstructor
public class MultipartConfig {
/**
* 멀티파트 파트 1개의 헤더 구간 최대 바이트.
* <p>
* commons-fileupload 1.6.0(CVE-2025-48976 수정)부터 이 상한이 생겼고 기본값이 512 바이트다.
* 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 금방 커진다 — 실측상 기본값 512 로는
* 한글 약 137자 이상 파일명에서 업로드가 실패하고, 예외도 "Maximum upload size exceeded" 로 감싸져
* 원인 파악이 어렵다. OS 파일명 상한(255자)이 전부 한글이어도 통과하도록 2048 로 둔다.
* (1.5 이전에는 사실상 10240 이었으므로 이 값도 그보다 5배 엄격하다.)
* </p>
*/
private static final int PART_HEADER_SIZE_MAX = 2048;
private final PortalProperties portalProperties;
/**
@@ -31,7 +45,14 @@ public class MultipartConfig {
*/
@Bean(name = "filterMultipartResolver")
public CommonsMultipartResolver filterMultipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
CommonsMultipartResolver resolver = new CommonsMultipartResolver() {
@Override
protected FileUpload newFileUpload(FileItemFactory fileItemFactory) {
FileUpload fileUpload = super.newFileUpload(fileItemFactory);
fileUpload.setPartHeaderSizeMax(PART_HEADER_SIZE_MAX);
return fileUpload;
}
};
long maxSizeBytes = portalProperties.getFile().getMaxSizeBytes();
resolver.setMaxUploadSize(maxSizeBytes);
@@ -39,8 +60,8 @@ public class MultipartConfig {
resolver.setMaxInMemorySize((int) Math.min(maxSizeBytes, Integer.MAX_VALUE));
resolver.setDefaultEncoding("UTF-8");
log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({})",
maxSizeBytes, portalProperties.getFile().getMaxSize());
log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({}), partHeaderSizeMax: {} bytes",
maxSizeBytes, portalProperties.getFile().getMaxSize(), PART_HEADER_SIZE_MAX);
return resolver;
}
@@ -2,11 +2,14 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.eactive.apim.portal.common.security.passwordcrypto.DecryptingMultipartRequestWrapper;
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -16,6 +19,7 @@ import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -62,12 +66,41 @@ public class PortalConfigSecurity {
return registration;
}
/**
* 비밀번호 전송암호화 복호화 필터.
*
* <p>order {@code MIN_VALUE + 2} — {@code PortalConfigWebDispatcherServlet} 의
* MultipartFilter({@code MIN_VALUE + 1}) <b>이후</b>, 아래 Lucy XSS 필터
* ({@code MIN_VALUE + 3}) <b>이전</b>에 실행되어야 한다.</p>
*
* <p>MultipartFilter 이후여야 하는 이유: 파일 업로드가 동반된 폼(예: 법인가입의 사업자등록증
* 첨부)은 {@code multipart/form-data} 로 제출되는데, Spring 의 {@code MultipartHttpServletRequest}
* 는 폼 필드 값을 자체 파싱해 {@code getParameter()} 에서 곧바로 반환하고 감싸고 있는 원본 요청으로
* 위임하지 않는다. 이 필터가 MultipartFilter 보다 먼저 실행되어(즉 더 안쪽에서) 요청을 감싸면,
* 멀티파트 안의 비밀번호 파라미터는 이 필터의 복호화 로직을 거치지 않고 봉투(ENC1) 문자열 그대로
* 컨트롤러까지 전달된다({@link DecryptingMultipartRequestWrapper} 주석 참고).</p>
*
* <p>Lucy XSS 필터 이전이어야 하는 이유: 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야
* 특수문자가 든 비밀번호의 해시 비교 결과가 지금과 동일하게 유지된다. 이 순서를 뒤집으면 기존
* 계정 로그인이 깨진다.</p>
*/
@Bean
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
PasswordDecryptFilter filter) {
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
registrationBean.setOrder(Integer.MIN_VALUE + 2);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
// order MIN_VALUE + 3 — 위 PasswordDecryptFilter(MIN_VALUE + 2) 다음에 실행되어야
// 복호화된 평문이 XSS 이스케이프를 거친다(순서를 뒤집으면 기존 계정 로그인이 깨짐).
@Bean
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
registrationBean.setFilter(xssEscapeServletFilter);
registrationBean.setOrder(Integer.MIN_VALUE + 1);
registrationBean.setOrder(Integer.MIN_VALUE + 3);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
@@ -92,6 +125,23 @@ public class PortalConfigSecurity {
http
.authenticationManager(portalAuthenticationManager)
// CVE-2026-22732 우회책. Spring Security 는 기본적으로 보안 헤더를 "응답 커밋 시점"에
// 지연 기록하는데, 응답 래퍼가 Content-Length 를 setHeader/setIntHeader/addIntHeader 로
// 지정하는 경로를 추적하지 못해 그 경우 헤더가 통째로 누락된다
// (X-Content-Type-Options, X-Frame-Options, Cache-Control, Pragma, Expires, X-XSS-Protection).
// 수정본은 5.7.22/5.8.24(Enterprise 전용)뿐이라 OSS 로는 올릴 수 없어 우회책을 적용한다.
// 요청 시작 시점에 헤더를 기록하게 만든다. 앱이 나중에 같은 헤더를 지정하면 앱 값이 남는다
// (실측: FileDownloadController#viewImage 의 Cache-Control: public, max-age=86400 유지됨).
// 현재 앱은 response.setContentLength(int) 만 쓰므로 노출 경로는 없지만,
// 새 코드가 위 메서드를 쓰더라도 헤더가 빠지지 않도록 두는 안전망이다.
// Spring Security 를 수정본(6.5.9+/7.0.4+ 또는 Enterprise 5.7.22+)으로 올리면 제거 가능.
.headers(headers -> headers.addObjectPostProcessor(new ObjectPostProcessor<HeaderWriterFilter>() {
@Override
public <O extends HeaderWriterFilter> O postProcess(O filter) {
filter.setShouldWriteHeadersEagerly(true);
return filter;
}
}))
.formLogin(form -> form
.loginPage("/login")
.usernameParameter("id")
@@ -175,31 +175,44 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
*
* <p>버전닝이 꺼져 있으면(prod 포함 — {@link #isResourceVersioningEnabled()} 참고) URL 이
* 고정이라 장기 {@code max-age} 캐시를 쓰면 배포 후에도 브라우저가 옛 내용을 계속 쓸 위험이 있다.
* 이 경우 {@code no-cache}(매 요청 {@code If-Modified-Since} 조건부 재검증, 변경 없으면 304)로
* 신선도를 보장한다 — OHS 가 정적 리소스를 직접 서빙해도 Apache 가 파일 mtime 기준으로 동일하게
* Last-Modified/조건부 GET 을 처리하므로 WAS 와 동작이 어긋나지 않는다
* (배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리).</p>
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
CacheControl cacheControl = isResourceCachingEnabled()
? CacheControl.maxAge(1, TimeUnit.DAYS)
: CacheControl.noStore();
boolean versioningOn = isResourceVersioningEnabled();
CacheControl cacheControl;
if (!isResourceCachingEnabled()) {
// no-store: 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다(dev/local).
cacheControl = CacheControl.noStore();
} else if (versioningOn) {
cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS);
} else {
cacheControl = CacheControl.noCache();
}
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(cacheControl)
.resourceChain(isResourceCachingEnabled());
if (isResourceVersioningEnabled()) {
if (versioningOn) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
chain.addResolver(new PathResourceResolver());
}
/**
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
* 정적자원 해시 버전닝 활성 여부. {@code app.resource-versioning.enabled} 값을 그대로 따른다.
*
* <p>과거 prod 프로파일은 이 토글과 무관하게 항상 ON 이었으나, OHS 가 정적 리소스를 직접
* 서빙하는 구조로 바뀌면서 OFF 로 전환했다 — 해시가 붙은 URL(main-&lt;hash&gt;.css)이 OHS
* 디스크의 무해시 원본과 어긋나 404 가 날 수 있어서다(prod.yml 에서 명시적으로 false 설정).
* 대신 캐시 신선도는 {@link #addStaticResourceHandler} 의 no-cache 조건부 재검증으로 보장한다.</p>
*/
private boolean isResourceVersioningEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceVersioningEnabled;
}
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// 연결 타임아웃: 10초
factory.setConnectTimeout(10000);
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
factory.setConnectTimeout(5000);
// 읽기 타임아웃: 30
factory.setReadTimeout(30000);
// 읽기 타임아웃: 5
factory.setReadTimeout(5000);
return new RestTemplate(factory);
}
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Controller
@RequestMapping("//notitest")
@RequestMapping("/djb/notitest")
@RequiredArgsConstructor
public class NotiTestController {
+6
View File
@@ -40,6 +40,12 @@ gateway:
portal:
# auth-virtual-code: 654321
security:
# 개발 서버는 HTTP(비 secure context)라 crypto.subtle 이 없다. software-fallback(forge)으로 암호화한다.
password-encrypt:
enabled: true
# 개발 서버는 원격 오리진 HTTP → crypto.subtle 이 없다. 기본값인 software-fallback(forge)으로 암호화된다.
plaintext-policy: permissive
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+18 -5
View File
@@ -9,9 +9,11 @@ spring:
cachecontrol:
max-age: 86400
public: true
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
# 정적자원 콘텐츠 해시 버전닝: OHS 가 정적 리소스(css/js/img 등)를 직접 서빙하는 구조에서
# 해시 URL(main-<hash>.css)이 OHS 디스크의 무해시 원본과 어긋나 404 위험이 있어 OFF 로 전환했다
# (아래 app.resource-versioning.enabled: false, PortalConfigWebDispatcherServlet 참고).
# 캐시 무효화는 대신 no-cache(매 요청 If-Modified-Since 조건부 재검증)로 보장한다.
# 배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리
jpa:
properties:
hibernate:
@@ -28,7 +30,7 @@ spring:
app:
resource-versioning:
enabled: true
enabled: false
resource-caching:
enabled: true
@@ -36,4 +38,15 @@ server:
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
forward-headers-strategy: framework
portal:
security:
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
password-encrypt:
enabled: true
# 운영은 평문 비밀번호를 받지 않는다. 봉투가 아니면 빈 값으로 치환돼 인증 실패로 떨어진다.
# 대가: JS 를 끈 브라우저는 로그인할 수 없고, 공개키 발급(/api/security/password-key.json)이
# 죽으면 로그인 전체가 막힌다(permissive 면 평문으로 degrade 되어 로그인은 됐을 상황).
# 되돌리려면 이 값을 permissive 로 바꾸고 재기동해야 한다 — yml 이라 무중단 토글은 안 된다.
plaintext-policy: enforce
+5
View File
@@ -22,6 +22,11 @@ spring:
portal:
# 검증 단계에선 사용하지 않음
# auth-virtual-code: 654321
security:
# HTTPS 구간이라 전송암호화를 켠다. plaintext-policy 승격(ENFORCE)은 운영 승격 전 검증 후 판단.
password-encrypt:
enabled: true
plaintext-policy: permissive
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+48
View File
@@ -153,6 +153,54 @@ portal:
user-approval: true
password-expiration-days: 90
security:
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
#
# 화면(password-crypto.js)이 서버 공개키로 AES 키를 감싸고 비밀번호를 그 AES 키로 암호화해
# ENC1.<keyId>.<랩된AES키>.<iv>.<암호문+태그> 봉투로 보낸다. 서버는 PasswordDecryptFilter 가
# 파라미터를 평문으로 되돌리므로 컨트롤러는 이 기능을 몰라도 된다.
#
# 주의: XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 털리고,
# HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다.
#
# 아래는 모든 키의 기본값이다. 프로파일 yml 에는 이 값과 다른 것만 적는다.
password-encrypt:
# 마스터 스위치. false 면 필터·키 발급 엔드포인트가 모두 무동작이고 화면은 평문 전송한다.
# 기본은 꺼둔다 — 켜는 것은 프로파일 yml 의 판단.
enabled: false
# RSA 개인키 보관 범위.
# REQUEST - 폼 진입마다 1회용 키 발급, 요청 1회 사용 후 폐기. 재전송 차단이 가장 강하다.
# SESSION - 세션 단위 보관. 세션 복제 환경에 유리.
# SERVER - 서버 고정 키쌍 + TTL 로테이션. 무상태라 단순하지만 재전송 방지 수단이 없다.
key-scope: REQUEST
# 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기.
key-ttl-seconds: 300
# 봉투가 씌워지지 않은 '평문 비밀번호 파라미터' 를 서버가 어떻게 다룰지.
# 전송 구간(HTTP/HTTPS)을 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
# none - 무동작. 평문을 그대로 받는다.
# permissive - 평문을 받되 서버 로그에 경고를 남기고(요청당 1줄), 화면에도 경고 팝업을 띄운다.
# HTTP 접속이면 비밀번호 입력 화면에서 세션당 1회 팝업이 뜬다.
# enforce - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).
# JS 를 끈 브라우저는 로그인하지 못하므로, permissive 로 운영하며 위 경고 로그가
# 안 나오는 것을 확인한 뒤에 승격한다.
# 기본은 none — 어느 환경에 올려도 기존 동작이 바뀌지 않게 한다.
# (enabled: false 면 필터가 통째로 무동작이라 이 값은 어차피 영향이 없다.)
plaintext-policy: none
# crypto.subtle 을 쓸 수 없는 환경에서 순수 JS 구현(forge)으로 봉투를 만들지 여부.
# 브라우저는 secure context(HTTPS·localhost)가 아니면 crypto.subtle 을 아예 노출하지 않는다.
# 즉 원격 오리진 HTTP(사내 IP 접속 등)에서는 이 값을 켜야 암호화가 걸린다.
# forge 번들(js/lib/forge-crypto.min.js, gzip 약 31KB)은 crypto.subtle 이 없을 때만 동적으로
# 내려가므로 HTTPS 구간에서는 전송 바이트가 0 이다. 사실상 킬스위치.
# 기본은 true — HTTPS 구간에서는 어차피 내려가지 않고, HTTP 구간에서는 켜져 있어야 의미가 있다.
software-fallback: true
# RSA 키 길이.
rsa-key-size: 2048
pages:
- path-pattern: /dashboard/daily_usage
method: GET
+109 -55
View File
@@ -1247,7 +1247,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -2574,7 +2574,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
transform: translateY(-3px);
}
.btn-danger {
@@ -2582,7 +2582,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2848,7 +2848,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2966,7 +2966,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -3041,7 +3041,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
transform: translateY(-2px);
color: #fff;
}
@@ -3091,7 +3091,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3131,7 +3131,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.badge-sm {
@@ -4319,7 +4319,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4640,7 +4640,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4795,7 +4795,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
.form-row--content .form-label-wrapper {
@@ -5067,6 +5067,8 @@ select.form-control {
color: #212529;
font-size: 20px;
line-height: 1.6;
word-break: keep-all;
overflow-wrap: break-word;
}
.modal-body p {
margin: 0;
@@ -5733,7 +5735,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6447,7 +6449,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6455,7 +6457,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
background-color: rgb(216.7625, 224.8125, 244.9375);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6463,7 +6465,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6471,7 +6473,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
}
.table-pagination {
@@ -7121,7 +7123,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
color: rgb(255, 70.8, 70.8);
align-items: center;
}
.alert.alert-error svg {
@@ -7135,7 +7137,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -11549,10 +11551,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
background: rgb(25.65, 70.3, 173.85);
}
.login-button:active {
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
background: rgb(24.3, 66.6, 164.7);
}
.login-button:disabled {
opacity: 0.6;
@@ -11595,10 +11597,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
background: rgb(220.61, 227.85, 245.95);
}
.login-links-container .link-btn:active {
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
background: rgb(205.22, 215.7, 241.9);
}
.login-alert {
@@ -12107,12 +12109,12 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
transform: none !important;
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
background: rgb(21.411588785, 146.3125233645, 233.148411215);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12161,10 +12163,10 @@ body.index-page-body {
background: #f0f2f5;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
background: rgb(225.45, 229.39, 235.3);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
background: rgb(210.9, 216.78, 225.6);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
@@ -12174,7 +12176,7 @@ body.index-page-body {
background: rgb(6, 54, 125);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12410,7 +12412,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17610,7 +17612,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17637,7 +17639,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
.btn-view-secret svg {
width: 20px;
@@ -17822,7 +17824,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
.btn-view-secret {
width: 100% !important;
@@ -17838,7 +17840,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
#revealedSecretBox {
width: 100%;
@@ -18111,7 +18113,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(74.3529411765%, 90.2296918768%, 100%);
background: rgb(189.6, 230.0857142857, 255);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18292,7 +18294,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18310,7 +18312,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(100%, 27.4868759774%, 25.1921568627%);
background: rgb(255, 70.0915337423, 64.24);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -19884,7 +19886,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -19917,7 +19919,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -19950,7 +19952,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -20022,7 +20024,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -20054,7 +20056,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -20114,7 +20116,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20122,7 +20124,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -20911,7 +20913,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21447,7 +21449,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
}
.org-file-notice {
@@ -22488,7 +22490,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
@@ -25273,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #666;
}
.api-statistics-container .statistics-date-range {
width: 260px;
max-width: 100%;
cursor: pointer;
}
.api-statistics-container .statistics-date-range:focus-visible {
outline: 2px solid #0049B4;
outline-offset: 2px;
border-radius: 6px;
}
.daterangepicker.statistics-date-picker {
z-index: 1100;
max-width: calc(100vw - 16px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.daterangepicker.statistics-date-picker .applyBtn {
color: #fff;
background: #0049B4;
border-radius: 4px;
}
@media (max-width: 560px) {
.daterangepicker.statistics-date-picker {
max-height: 70vh;
overflow-y: auto;
}
}
.btn-search {
background: #0049B4;
border: none;
@@ -27135,7 +27165,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.api-status .as-alert-card .as-tl-row {
display: grid;
grid-template-columns: 60px 76px 1fr;
grid-template-columns: 60px 76px 64px 1fr;
gap: 12px;
font-size: 13px;
}
@@ -27143,6 +27173,37 @@ input[type=checkbox]:checked + .custom-checkbox {
color: var(--as-muted);
font-variant-numeric: tabular-nums;
}
.api-status .as-alert-card .as-tl-row .as-tl-state {
font-size: 11px;
font-weight: 500;
text-align: center;
height: -webkit-fit-content;
height: fit-content;
padding: 2px 8px;
border-radius: var(--as-pill);
background: var(--as-gray-bg);
color: var(--as-text-2);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-INVESTIGATING {
background: var(--as-err-bg);
color: var(--as-err);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-IDENTIFIED {
background: #fbe9d7;
color: #c95a0f;
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-MONITORING {
background: var(--as-warn-bg);
color: var(--as-warn);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-RESOLVED {
background: var(--as-ok-bg);
color: var(--as-ok);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-CANCELED {
background: var(--as-gray-bg);
color: var(--as-muted);
}
.api-status .as-alert-card .as-tl-row .as-tl-who {
font-size: 11px;
font-weight: 500;
@@ -27292,9 +27353,6 @@ input[type=checkbox]:checked + .custom-checkbox {
padding: 22px 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.api-status .as-maint-card.is-ongoing {
border-left-color: var(--as-warn);
}
.api-status .as-maint-card .as-maint-head {
display: flex;
align-items: baseline;
@@ -27306,7 +27364,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--as-text);
color: var(--as-info);
}
.api-status .as-maint-card .as-schedule {
display: inline-flex;
@@ -27320,10 +27378,6 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: var(--as-pill);
font-variant-numeric: tabular-nums;
}
.api-status .as-maint-card.is-ongoing .as-schedule {
color: var(--as-warn);
background: var(--as-warn-bg);
}
.api-status .as-maint-card .as-maint-body {
margin: 14px 0 12px;
font-size: 14px;
@@ -27720,7 +27774,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-bottom: 12px;
}
.api-status .as-alert-card .as-tl-row {
grid-template-columns: 52px 1fr;
grid-template-columns: 52px 68px 1fr;
}
.api-status .as-alert-card .as-tl-who {
display: none;
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

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

-491
View File
@@ -1,491 +0,0 @@
class ChartEditor {
constructor(container, layoutManager, dashboard) {
this.rootContainer = container;
this.layoutManager = layoutManager;
this.dashboard = dashboard;
this.targetContainerIndex = -1;
this.targetColumnIndex = -1;
this.tempChart = null;
this.chartPreview = null;
this.containerHeight = 0;
this.chartEditorModel = [
{
'groupName': 'default',
'groupTitle': '기본 정보',
'fields': [
{
'path': 'contentId',
'type': 'text',
'label': 'Chart ID',
'placeholder': 'chartId를 입력하세요'
},
{
'path': 'styles.width',
'type': 'text',
'label': '길이(px, %)',
'placeholder': 'px, % 등 단위를 포함한 길이를 입력하세요'
},
{
'path': 'styles.height',
'type': 'text',
'label': '높이(px, %)',
'placeholder': 'px, % 등 단위를 포함한 높이를 입력하세요'
},
{
'path': 'config.animation',
'type': 'boolean',
'label': 'Animation 사용'
}
]
},
{
'groupName': 'title',
'groupTitle': '제목',
'fields': [
{
'path': 'config.title.text',
'type': 'text',
'label': '차트 제목',
'placeholder': '차트 제목을 입력하세요'
}
]
},
{
'groupName': 'tooltip',
'groupTitle': 'Tooltip',
'fields': [
{
'path': 'config.tooltip.show',
'type': 'boolean',
'label': 'Tooltip 표시'
},
{
'path': 'config.tooltip.trigger',
'type': 'select',
'label': 'Tooltip Trigger',
'placeholder': 'Enter the trigger type',
'options': ['item', 'axis', 'none']
}
]
},
{
'groupName': 'legend',
'groupTitle': 'Legend',
'fields': [
{
'path': 'config.legend.show',
'type': 'boolean',
'label': 'Legend 표시'
}
]
},
{
'groupName': 'grid',
'groupTitle': 'Grid',
'fields': [
{
'path': 'config.grid.show',
'type': 'boolean',
'label': 'Grid 표시'
},
{
'path': 'config.grid.left',
'type': 'text',
'label': 'Grid Left',
'placeholder': '좌측 여백'
},
{
'path': 'config.grid.right',
'type': 'text',
'label': 'Grid Right',
'placeholder': '우측 여백'
},
{
'path': 'config.grid.top',
'type': 'text',
'label': 'Grid Top',
'placeholder': '위쪽 여백'
},
{
'path': 'config.grid.bottom',
'type': 'text',
'label': 'Grid Bottom',
'placeholder': '아래쪽 여백'
},
{
'path': 'config.grid.containLabel',
'type': 'boolean',
'label': 'Contain Label'
}
]
},
{
'groupName': 'xAxis',
'groupTitle': 'X축',
'fields': [
{
'path': 'config.xAxis.show',
'type': 'boolean',
'label': 'X축 표시'
},
{
'path': 'config.xAxis.type',
'type': 'select',
'label': 'X축 유형',
'placeholder': 'X축 유형을 선택하세요',
'options': ['category', 'value', 'time']
}
]
},
{
'groupName': 'yAxis',
'groupTitle': 'Y축',
'fields': [
{
'path': 'config.yAxis[0].show',
'type': 'boolean',
'label': 'Y축 표시'
},
{
'path': 'config.yAxis[0].name',
'type': 'text',
'label': 'Y축 이름'
},
{
'path': 'config.yAxis[0].type',
'type': 'select',
'label': 'Y축 유형',
'placeholder': 'Y축 유형을 선택하세요',
'options': ['category', 'value', 'time']
},
{
'path': 'config.yAxis[0].position',
'type': 'select',
'label': 'Y축 위치',
'placeholder': 'Y축 위치를 선택하세요',
'options': ['left', 'right']
}
]
},
{
'groupName': 'toolbox',
'groupTitle': 'Toolbox',
'fields': [
{
'path': 'config.toolbox.show',
'type': 'boolean',
'label': 'Toolbox 표시',
'placeholder': 'Check to show toolbox'
},
{
'path': 'config.toolbox.feature.saveAsImage.show',
'type': 'boolean',
'label': 'Save as Image 사용'
}
]
}
];
this.seriesConfigModel = [
{
'groupName': 'default',
'groupTitle': '시리즈 정보',
'fields': [
{
'path': 'seriesConfig[0].queryType',
'type': 'select',
'label': 'Query Type',
'options': ['elasticsearch']
},
// {
// 'path': 'seriesConfig[0].type',
// 'type': 'select',
// 'label': '차트 유형',
// 'placeholder': '차트 유형을 선택하세요',
// 'options': ['bar', 'line', 'pie']
// },
{
'path': 'seriesConfig[0].index',
'type': 'text',
'label': 'Elasticsearch Index',
'placeholder': 'Elasticsearch Index를 입력하세요'
},
{
'path': 'seriesConfig[0].query',
'type': 'textarea',
'label': 'Elasticsearch Query'
},
{
'path': 'seriesConfig[0].queryResult',
'type': 'textarea',
'label': 'Elasticsearch Query Result'
},
{
'path': 'seriesConfig[0].dataPath',
'type': 'text',
'label': '데이터 경로',
'placeholder': '데이터 경로를 입력하세요'
}
]
}
];
}
initChartEditor() {
const chartEditorPopup = `
<div class="modal fade" id="chart_editor_modal" tabindex="-1" aria-labelledby="chart_editor_modal" aria-hidden="true">
<div class="modal-dialog modal-fullscreen modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">차트 정보</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="editor_container">
<input type="hidden" name="type" value="" />
<div class="left-panel default-info">
${LayoutUtil.createForm(this.chartEditorModel)}
</div>
<div class="editor-right-panel">
<div class="series-config d-flex flex-column">
<h6>Data Source</h6>
<div class="d-flex flex-row">
<div class="form-floating me-2" style="flex: 1">
<select class="form-select" name="seriesConfig[0].queryType">
<option value="elasticsearch">Elasticsearch</option>
</select>
<label for="queryType">Query Type</label>
</div>
<div class="form-floating me-2" style="flex: 1">
<input class="form-control" type="text" name="seriesConfig[0].index" value="" />
<label for="index">Elasticsearch Index</label>
</div>
</div>
<div class="d-flex flex-row mt-2" style="flex: 1">
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" name="seriesConfig[0].query" id="seriesConfigQuery" placeholder="Code goes here..." style="height: 400px;">
</textarea>
</div>
</div>
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" name="seriesConfig[0].queryResult" style="height: 400px;" readonly>
</textarea>
<label for="query">요청 결과</label>
</div>
</div>
</div>
<div class="d-flex flex-row mt-2" style="flex: 1">
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" name="seriesConfig[0].mapperY" style="height: 400px;">
</textarea>
<label for="mapper">데이터 Mapper</label>
</div>
</div>
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" name="seriesConfig[0].mapperX" style="height: 400px;">
</textarea>
<label for="mapper">X축 Mapper</label>
</div>
</div>
</div>
<div class="d-flex flex-row" style="height: 400px">
<div style="flex: 1">
<div class="form-floating me-2" style="height: 100%">
<textarea class="form-control" type="text" id="echartOption" style="width: 100%; height: 400px;" readonly>
</textarea>
<label for="mapper">생성된 Option</label>
</div>
</div>
<div style="flex: 1">
<div class="chart-preview form-control" id="chart-preview" style="height: 400px;">
chart_preview
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
<button type="button" class="btn btn-primary btn-sm add-chart">추가</button>
<button type="button" class="btn btn-primary btn-sm save-chart">저장</button>
</div>
</div>
</div>
</div>`;
$(this.rootContainer).parent().append(chartEditorPopup);
//add event to modal show
$('#chart_editor_modal').on('shown.bs.modal', (event) => {
let chartContainer = document.getElementById('chart-preview');
let rect = chartContainer.getBoundingClientRect();
let width = rect.width; // Precise width
let height = rect.height; // Precise height
this.chartPreview = echarts.init(document.getElementById('chart-preview'), 'dark', {
width: width, height: height
});
this.drawCharts();
});
}
showChartEditor(isNew, tempChart, containerIndex, columnIndex) {
this.targetContainerIndex = containerIndex;
this.targetColumnIndex = columnIndex;
this.tempChart = tempChart;
if (isNew) {
$('.add-chart').show();
$('.save-chart').hide();
} else {
$('.add-chart').hide();
$('.save-chart').show();
}
let flatModel = LayoutUtil.flattenObject(tempChart);
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
if ($(element).attr('type') === 'checkbox') {
$(element).prop('checked', false);
}
$(element).val('');
});
_.forEach(flatModel, (value, key) => {
const inputSelector = `input[name="${key}"]`;
const selectSelector = `select[name="${key}"]`;
const textareaSelector = `textarea[name="${key}"]`;
const $input = $('#chart_editor_modal').find(inputSelector);
const $select = $('#chart_editor_modal').find(selectSelector);
const $textarea = $('#chart_editor_modal').find(textareaSelector);
if ($input.length > 0 && $input.attr('type') === 'checkbox') {
// If it's a checkbox, update its 'checked' property
$input.prop('checked', value);
} else if ($input.length > 0 || $select.length > 0 || $textarea.length > 0) {
// If any input, select, or textarea is found, update its value
$($input.add($select).add($textarea)).val(value);
} else {
// If no elements are found, log to the console
console.log(`Element not found for name: ${key}`);
}
});
$('#chart_editor_modal').modal('show');
console.log('chart_editor_modal show');
this.bindEvents();
}
addChart() {
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
const fieldName = $(element).attr('name');
const newValue = LayoutUtil.getFieldValue(element);
if (fieldName !== '') {
_.set(this.tempChart, fieldName, newValue);
}
});
this.layoutManager.layoutData.children[this.targetContainerIndex].children.push(JSON.parse(JSON.stringify(this.tempChart)));
this.tempChart = null;
this.targetContainerIndex = -1;
this.targetColumnIndex = -1;
this.layoutManager.resetLayout();
$('#chart_editor_modal').modal('hide');
}
cleanUpEvents() {
console.log('cleanUpEvents');
$('.add-chart').off('click');
$('.save-chart').off('click');
}
saveChart() {
console.log('saveChart');
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
const fieldName = $(element).attr('name');
const newValue = LayoutUtil.getFieldValue(element);
if (fieldName !== undefined && fieldName !== '') {
_.set(this.tempChart, fieldName, newValue);
}
});
const json = JSON.stringify(this.tempChart, LayoutUtil.echartReplacer, 2);
console.log(json);
this.layoutManager.layoutData.children[this.targetContainerIndex].children[this.targetColumnIndex] = JSON.parse(json);
this.targetContainerIndex = -1;
this.targetColumnIndex = -1;
this.layoutManager.resetLayout();
$('#chart_editor_modal').modal('hide');
}
refreshChart(chartInfo) {
console.log('refreshChart');
return new Promise((resolve, reject) => {
let option = JSON.parse(JSON.stringify(chartInfo.config));
option.series = [];
let self = this;
Promise.all(this.tempChart.seriesConfig.map(config => this.dashboard.requestQuery(config))).then(async (results) => {
await self.dashboard.updateChartSeries(option, results, chartInfo, (index, result) => {
$('#chart_editor_modal').find(`textarea[name='seriesConfig[${index}].queryResult']`).val('result = ' + JSON.stringify(result, null, 2));
});
document.getElementById('echartOption').value = JSON.stringify(option, null, 2);
self.chartPreview.setOption(option);
resolve();
}).catch((error) => {
console.error('Error fetching data for preview: ', error);
reject(error);
});
});
}
drawCharts() {
console.log('drawCharts');
const promises = [this.refreshChart(this.tempChart)];
Promise.all(promises).then(() => console.log('Chart Preview is completed')).catch(error => console.error('An error occurred:', error));
}
bindEvents() {
$('.add-chart').off('click').on('click', (event) => {
this.addChart();
});
$('.save-chart').off('click').on('click', (event) => {
this.saveChart();
});
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
$(element).off('change').on('change', (event) => {
console.log('change event');
const fieldName = $(event.currentTarget).attr('name');
const newValue = LayoutUtil.getFieldValue(event.currentTarget);
if (fieldName !== '') {
_.set(this.tempChart, fieldName, newValue);
}
this.refreshChart(this.tempChart);
});
});
$('#chart_editor_modal').on('hidden.bs.modal', (event) => {
if (this.chartPreview != null && this.chartPreview.dispose) {
this.chartPreview.dispose();
this.chartPreview = null;
}
this.cleanUpEvents();
});
}
}
-344
View File
@@ -1,344 +0,0 @@
class Dashboard {
constructor(layoutData) {
this.layoutData = layoutData || {};
this.fromDateTarget = 'fromDate';
this.toDateTarget = 'toDate';
this.charts = [];
this.theme = 'dark';
this.requestUrl = '/mgmt/dashboard/search.do';
}
bindEvents() {
const events = [{selector: '.refresh', action: this.refresh.bind(this)}];
for (let event of events) {
$(document).off('click', event.selector).on('click', event.selector, event.action);
}
}
getDateTemplate(id, label) {
return `
<div class="input-group mb-3">
<div class="form-floating">
<input id="${id}Input" type="text" class="form-control" data-td-target="#${id}"/>
<label for="${id}Input">${label}</label>
</div>
<span class="input-group-text" data-td-target="#${id}" data-td-toggle="datetimepicker">
<span class="fa-solid fa-calendar"></span>
</span>
</div>
`;
}
init(chartAreaId) {
console.log('Dashboard init');
this.chartAreaId = chartAreaId;
this.initDate();
this.initCharts();
this.drawCharts();
window.addEventListener('resize', this.resizeAllCharts.bind(this));
}
initCharts() {
console.log('initCharts');
this.charts = [];
if (this.layoutData.children) {
this.layoutData.children.forEach((row) => {
row.children.forEach((chartInfo) => {
this.charts.push(chartInfo);
});
});
}
}
formatDate(isoString) {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // +1 because getMonth() returns 0-11
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
formatTime(isoString) {
const date = new Date(isoString);
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${hour}:${minute}:00`;
}
formatDateTime(isoString) {
return this.formatDate(isoString) + 'T' + this.formatTime(isoString);
}
startOfDay(isoString) {
return this.formatDate(isoString) + 'T00:00:00';
}
initDate() {
const ko = tempusDominus.locales.ko.localization;
const fromDateElement = document.getElementById(this.fromDateTarget);
$(fromDateElement).data('td-target-input', 'nearest');
$(fromDateElement).data('td-target-toggle', 'nearest');
const toDateElement = document.getElementById(this.toDateTarget);
$(toDateElement).data('td-target-input', 'nearest');
$(toDateElement).data('td-target-toggle', 'nearest');
$(fromDateElement).append(this.getDateTemplate(this.fromDateTarget, 'From'));
$(toDateElement).append(this.getDateTemplate(this.toDateTarget, 'To'));
this.fromDate = new tempusDominus.TempusDominus(fromDateElement, {
localization: ko, defaultDate: this.startOfDay(new Date())
});
this.toDate = new tempusDominus.TempusDominus(toDateElement, {
useCurrent: false, localization: ko, defaultDate: new Date()
});
fromDateElement.addEventListener(tempusDominus.Namespace.events.change, (e) => {
this.toDate.updateOptions({
restrictions: {
minDate: e.detail.date
}
});
});
const subscription = this.toDate.subscribe(tempusDominus.Namespace.events.change, (e) => {
this.fromDate.updateOptions({
restrictions: {
maxDate: e.date
}
});
});
}
requestPost(url, data, hds) {
var deferred = $.Deferred();
var headers = $.extend({
'Content-Type': 'application/json; charset=utf-8'
}, hds);
if (headers['Content-Type'].startsWith('application/json')) {
data = JSON.stringify(data);
}
$.ajax({
url: url, async: true, method: 'POST', data: data, headers: headers
}).done((response) => {
deferred.resolve(response);
}).fail((response, textStatus, errorThrown) => {
deferred.reject(response, textStatus, errorThrown);
});
return deferred.promise();
};
requestQuery(seriesConfig) {
let request = null;
try {
if (seriesConfig.queryType === 'elasticsearch') {
if (seriesConfig.query && seriesConfig.index !== '') {
let requestQuery = JSON.parse(seriesConfig.query);
requestQuery.query = requestQuery.query || {};
requestQuery.query.bool = requestQuery.query.bool || {};
requestQuery.query.bool.filter = requestQuery.query.bool.filter || [];
requestQuery.query.bool.filter.push({
'range': {
'@timestamp': {
'gte': this.formatDateTime(this.fromDate.dates.picked[0]),
'lte': this.formatDateTime(this.toDate.dates.picked[0]),
'time_zone': 'Asia/Seoul'
}
}
});
request = {
query: JSON.stringify(requestQuery), index: seriesConfig.index
};
return this.requestPost(this.requestUrl, request, {});
} else {
console.log('query is empty');
return Promise.resolve();
}
}
} catch (error) {
console.error('Error fetching data for ' + seriesConfig.contentId + ': ', error);
return Promise.reject(error);
}
}
resizeAllCharts() {
this.charts.forEach(chartInfo => {
let chartContainer = document.getElementById('chart_' + chartInfo.contentId);
let rect = chartContainer.getBoundingClientRect();
let width = rect.width; // Precise width
let height = rect.height; // Precise height
if (chartInfo.echart) {
chartInfo.echart.resize({width: width, height: height});
}
});
}
initializeChart(chartInfo) {
console.log('initializeChart: ' + chartInfo.contentId);
let chartContainer = document.getElementById('chart_' + chartInfo.contentId);
let rect = chartContainer.getBoundingClientRect();
let width = rect.width; // Precise width
let height = rect.height; // Precise height
chartInfo.echart = echarts.init(document.getElementById('chart_' + chartInfo.contentId), this.theme, {
width: width, height: height
});
}
executeScript(result, script) {
console.log('executeScript');
if (script) {
return new Promise((resolve, reject) => {
let resultFrame = document.getElementById('mapperScript');
window.preRequestComplete = (result, error) => {
if (error) {
console.log('Error executing script: ', error);
reject(error);
} else {
resolve(result);
}
};
window.result = result;
resultFrame.srcdoc = `<script>
function get(obj, path, defaultValue = undefined) {
const keys = Array.isArray(path) ? path : path.replace(/\\[(\\d+)\\]/g, '.$1').split('.');
let result = obj;
for (let key of keys) {
result = result?.[key];
if (result === undefined) {
return defaultValue;
}
}
return result;
}
function formatDate(isoString) {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // +1 because getMonth() returns 0-11
const day = String(date.getDate()).padStart(2, '0');
return \`\${year}-\${month}-\${day}\`;
}
function formatTime(isoString) {
const date = new Date(isoString);
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return \`\${hour}:\${minute}:00\`;
}
function formatDateTime(isoString) {
return formatDate(isoString) + 'T' + formatTime(isoString);
}
function startOfDay(isoString) {
return formatDate(isoString) + 'T00:00:00';
}
window.result = window.parent.result;
try {
const executeFunction = ${script};
const data = executeFunction(window.result);
window.parent.preRequestComplete(data, null);
} catch (error) {
window.parent.preRequestComplete(null, error);
}
</script>`;
});
} else {
console.log('No script to execute');
return Promise.resolve();
}
}
async refresh(event) {
console.log('refresh');
for (let i = 0; i < this.charts.length; i++) {
const chartInfo = this.charts[i];
try {
await this.refreshChart(chartInfo);
} catch (error) {
console.error(`Error refreshing chart ${chartInfo.contentId}:`, error);
}
}
console.log('All Charts requests are complete');
// const promises = this.charts.map(info => this.refreshChart(info));
// Promise.all(promises).then(() => console.log('All Charts requests are complete')).catch(error => console.error('An error occurred:', error));
}
drawCharts() {
console.log('drawCharts');
this.charts.forEach(info => this.initializeChart(info));
this.refresh();
this.bindEvents();
}
async updateChartSeries(option, results, chartInfo, callback) {
console.log('updateChartSeries');
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result){
let config = chartInfo.seriesConfig[i];
let parsedResult = JSON.parse(result);
if (callback) {
callback(i, parsedResult);
}
try {
console.log('start executeScript');
const dataY = await this.executeScript(parsedResult, config.mapperY);
if (Array.isArray(dataY)) {
dataY.forEach(item => {
option.series.push(item);
});
} else {
option.series.push(dataY);
}
const dataX = await this.executeScript(parsedResult, config.mapperX);
option.xAxis.data = dataX;
console.log('stop executeScript');
} catch (error) {
console.error(`Error updating series for chart ${chartInfo.contentId}:`, error);
}
}
}
}
async refreshChart(chartInfo) {
try {
console.log('refreshChart');
let option = JSON.parse(JSON.stringify(chartInfo.config));
option.series = [];
const results = await Promise.all(chartInfo.seriesConfig.map(config => this.requestQuery(config)));
await this.updateChartSeries(option, results, chartInfo);
// Clear the chart and set the new option
chartInfo.echart.clear();
chartInfo.echart.setOption(option);
console.log('Chart refreshed successfully');
} catch (error) {
console.error('Error fetching data for ' + chartInfo.contentId + ': ', error);
throw error; // Rethrow the error if you need to catch it higher up in the call stack
}
}
}
@@ -1,643 +0,0 @@
class LayoutManager {
constructor(container, layoutData) {
this.rootContainer = container;
this.tempContainer = null;
this.editContainerIndex = -1;
this.layoutData = layoutData || {
type: 'root',
styles: {
'display': 'flex',
'flex-direction': 'column',
'justify-content': 'start',
'align-items': 'flex-start',
'gap': '10px',
'width': '100%',
'height': '100vh',
'position': 'relative',
'overflow': 'auto',
'background-color': '#100C2A'
},
children: []
};
this.containerEditorModel = [
{
'groupName': 'default',
'groupTitle': '기본',
'fields': [
{
'path': 'styles.display',
'type': 'select',
'label': 'Display',
'options': ['flex']
},
{
'path': 'styles.flex-direction',
'type': 'select',
'label': 'Flex 방향',
'options': ['row', 'column', 'row-reverse', 'column-reverse']
},
{
'path': 'styles.width',
'type': 'text',
'label': '길이(px, %)',
'placeholder': 'px, % 등 단위를 포함한 길이를 입력하세요'
},
{
'path': 'styles.height',
'type': 'text',
'label': '높이(px, %)',
'placeholder': 'px, % 등 단위를 포함한 높이를 입력하세요'
}
]
}
];
this.dashboardEditorModel = [
{
'groupName': 'default',
'groupTitle': '기본',
'fields': [
{
'path': 'styles.background-color',
'type': 'text',
'label': '색상',
'placeholder': '#로 시작하는 값을 입력하세요'
},
{
'path': 'styles.width',
'type': 'text',
'label': '길이(px)',
'placeholder': 'px 단위를 포함한 길이를 입력하세요'
},
{
'path': 'styles.height',
'type': 'text',
'label': '높이(px)',
'placeholder': 'px 단위를 포함한 높이를 입력하세요'
}
]
}
]
}
// traverse(obj) {
// if (obj.type === 'chart') {
// $(`#chart_${obj.contentId}`).closest('.dashboard-chart').resizable({
// grid: 10,
// ghost: true,
// stop: (event, ui) => {
// const {width, height} = ui.size;
// obj.styles.width = `${width}px`;
// obj.styles.height = `${height}px`;
// this.resetLayout();
// }
// });
// }
//
// if (obj.children) {
// obj.children.forEach(child => {
// this.traverse(child);
// });
// }
// }
init(Dashboard, viewerMode) {
this.viewMode = viewerMode || false;
this.renderRoot(this.layoutData, this.rootContainer);
if (Dashboard) {
this.dashboard = new Dashboard(this.layoutData);
this.dashboard.init('chart_area');
}
if (!this.viewMode) {
this.initContainerEditor();
this.initDashboardEditor();
this.chartEditor = new ChartEditor(this.rootContainer, this, this.dashboard);
this.chartEditor.initChartEditor();
this.bindEvents();
}
}
initContainerEditor() {
const containerEditorPopup = `
<div class="modal fade" id="container_editor_modal" tabindex="-1" aria-labelledby="container_editor_modal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">컨테이너 정보</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="containerOption">
${LayoutUtil.createForm(this.containerEditorModel)}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
<button type="button" class="btn btn-primary btn-sm add-container">추가</button>
<button type="button" class="btn btn-primary btn-sm save-container">저장</button>
</div>
</div>
`;
$(this.rootContainer).parent().append(containerEditorPopup);
}
initDashboardEditor() {
const containerEditorPopup = `
<div class="modal fade" id="dashboard_editor_modal" tabindex="-1" aria-labelledby="dashboard_editor_modal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">대시보드 정보</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="containerOption">
${LayoutUtil.createForm(this.dashboardEditorModel)}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
<button type="button" class="btn btn-primary btn-sm save-dashboard">적용</button>
</div>
</div>
`;
$(this.rootContainer).parent().append(containerEditorPopup);
}
resetLayout() {
console.log('resetLayout');
$(this.rootContainer).empty();
this.renderRoot(this.layoutData, this.rootContainer);
this.dashboard.initCharts();
this.dashboard.layoutData = this.layoutData;
this.dashboard.drawCharts();
this.editContainerIndex = -1;
this.bindEvents();
}
addNewChart(containerIndex) {
this.editContainerIndex = containerIndex;
let tempChart = {
'contentId': '',
'type': 'chart',
'styles': {
width: '100%', height: '100%'
},
'config': {
'tooltip': {
'trigger': 'axis',
'show': true
},
'legend': {
'show': true
},
'grid': {
'show': true,
'top': 0,
'bottom': 0,
'left': 0,
'right': 0,
'containLabel': true
},
'title': {},
'xAxis': {
'show': true,
'type': 'category'
},
'yAxis': [
{
'show': true,
'name': '',
'type': 'value',
'position': 'left'
}],
'toolbox': {
'show': true
}
},
'seriesConfig': [
{
'type': 'line',
'index': '',
'queryType': 'elasticsearch',
'query': '',
'mapperX': `
function(result) {
let data = [];
return data;
}
`,
'mapperY': `
function(result) {
let data = [{
type : 'line',
data: []
}];
return data;
}
`,
'queryResult': ''
}
]
};
this.chartEditor.showChartEditor(true, tempChart, containerIndex);
}
editChart(editContainerIndex, editColumnIndex) {
console.log('edit-chart');
let tempChart = this.layoutData.children[editContainerIndex].children[editColumnIndex];
this.chartEditor.showChartEditor(false, JSON.parse(JSON.stringify(tempChart, LayoutUtil.echartReplacer)), editContainerIndex, editColumnIndex);
}
renderRoot(layoutData, parent) {
let classList = 'dashboard-root mt-2' + (!this.viewMode ? ' edit' : '');
let children = '';
let style = '';
if (layoutData.styles) {
style = Object.keys(layoutData.styles).map(key => `${key}: ${layoutData.styles[key]};`).join(' ');
}
console.log({style});
if (layoutData.children) {
layoutData.children.forEach((child) => {
if (child.type === 'container') {
children += this.renderContainer(child);
} else if (child.type === 'chart') {
children += this.renderChart(child);
}
});
}
let layoutItem = `<div class="${classList}" style="${style}"> ${children}</div>`;
$(parent).append(layoutItem);
// if (!this.viewMode) {
// this.traverse(this.layoutData);
// }
};
renderChart(layoutData) {
let style = '';
if (layoutData.styles) {
style = Object.keys(layoutData.styles).map(key => `${key}: ${layoutData.styles[key]};`).join(' ');
}
let editButton = this.viewMode ? '' : `<div class="chart-menu-icon" data-content-id="chart_${layoutData.contentId}">
<div class="dropdown">
<span class="" data-bs-toggle="dropdown" aria-expanded="false">⋮</span>
<ul class="dropdown-menu">
<li><span class="dropdown-item edit-chart">차트 편집</span></li>
<li><span class="dropdown-item remove-chart">차트 제거</span></li>
</ul>
</div>
</div>`;
return `
<div class="dashboard-chart" data-content-id="chart_${layoutData.contentId}" style="${style}">
${editButton}
<div id="chart_${layoutData.contentId}" style="width: 100%; height: 100%"></div>
</div>
`;
}
renderContainer(layoutData) {
console.log('renderContainer');
let children = '';
if (layoutData.children) {
layoutData.children.forEach(child => {
if (child.type === 'container') {
children += this.renderContainer(child);
} else if (child.type === 'chart') {
children += this.renderChart(child);
}
});
}
let width = layoutData.styles.width || '100%';
let height = layoutData.styles.height || '100%';
let editButton = this.viewMode ? '' : `
<div class="container-menu-icon">
<div class="dropdown">
<span><i class="fa-solid fa-arrows-up-down-left-right"></i></span>
<span class="" data-bs-toggle="dropdown" aria-expanded="false">⋮</span>
<ul class="dropdown-menu">
<li><span class="dropdown-item edit-container">컨테이너 편집</span></li>
<li><span class="dropdown-item remove-container">컨테이너 제거</span></li>
<li><span class="dropdown-item add-new-chart">차트 추가</span></li>
</ul>
</div>
</div>`;
let style = '';
if (layoutData.styles) {
style = Object.keys(layoutData.styles).map(key =>{
if (key === 'width' || key === 'height') return `${key}: 100%;`;
return `${key}: ${layoutData.styles[key]};`;
} ).join(' ');
}
return `<div class="dashboard-container" style="width: ${width}; height: ${height};">
<div class="handle">
${editButton}
</div>
<div class="dashboard-container-children" style="${style}">
${children}
</div>
</div>`;
}
addNewContainer(event) {
this.tempContainer = {
type: 'container',
styles: {
display: 'flex',
'flex-direction': 'row',
width: '100%',
height: '300px'
},
children: []
};
this.showContainerEditor(true);
}
editContainer(editContainerIndex) {
console.log('edit-container: ' + editContainerIndex);
this.tempContainer = JSON.parse(JSON.stringify(this.layoutData.children[editContainerIndex], LayoutUtil.echartReplacer));
this.showContainerEditor(false);
this.editContainerIndex = editContainerIndex;
}
removeContainer(containerIndex) {
console.log('edit-container: ' + containerIndex);
this.layoutData.children.splice(containerIndex, 1);
this.resetLayout();
}
addContainer(event) {
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
const fieldName = $(element).attr('name');
const newValue = LayoutUtil.getFieldValue(element);
if (fieldName !== '') {
_.set(this.tempContainer, fieldName, newValue);
}
});
this.layoutData.children.push(JSON.parse(JSON.stringify(this.tempContainer)));
this.tempContainer = null;
this.resetLayout();
$('#container_editor_modal').modal('hide');
}
saveContainer(event) {
console.log('save-container: ' + this.editContainerIndex);
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
const fieldName = $(element).attr('name');
const newValue = LayoutUtil.getFieldValue(element);
if (fieldName !== '') {
_.set(this.tempContainer, fieldName, newValue);
}
});
this.layoutData.children[this.editContainerIndex] = JSON.parse(JSON.stringify(this.tempContainer));
this.tempContainer = null;
this.resetLayout();
$('#container_editor_modal').modal('hide');
}
removeChart(containerIndex, columnIndex) {
this.layoutData.children[containerIndex].children.splice(columnIndex, 1);
this.resetLayout();
}
onRowsSorted(ui) {
let originalContainerIndex = ui.item.data('original_row_index');
this.updateLayoutDataRows(originalContainerIndex, ui);
}
onColumnsSorted(ui) {
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
let originalContainerIndex = ui.item.data('original_row_index');
if (originalContainerIndex) {
if (originalContainerIndex === containerIndex) { // Check if the column moved from another row
this.updateLayoutDataWithinRow(ui.item);
} else { // The column was sorted within the same row
this.updateLayoutDataColumns(ui.item);
}
this.resetLayout();
}
}
updateLayoutDataRows(originalContainerIndex, ui) {
let targetIndex = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
if (targetIndex !== originalContainerIndex) {
this.layoutData.children.splice(targetIndex, 0, this.layoutData.children.splice(originalContainerIndex, 1)[0]);
}
console.log(this.layoutData);
}
updateLayoutDataWithinRow(movedItem) {
let oldContainerIndex = movedItem.data('original_row_index');
let oldIndex = movedItem.data('original_column_index');
if (oldContainerIndex !== -1 && oldIndex !== -1) {
let $row = movedItem.closest('.dashboard-container');
let newIndex = $row.children('.dashboard-chart').index(movedItem);
this.layoutData.children[oldContainerIndex].children.splice(newIndex, 0, this.layoutData.children[oldContainerIndex].children.splice(oldIndex, 1)[0]);
}
}
save() {
const jsonString = JSON.stringify(this.layoutData, LayoutUtil.echartReplacer);
return jsonString;
}
updateLayoutDataColumns(movedItem) {
// 새로운 행과 이전 행의 jQuery 요소를 가져옵니다
let $newRow = movedItem.closest('.dashboard-container');
// 이전 행의 레이아웃 데이터에서 이동된 행과 열의 인덱스를 찾습니다
let oldContainerIndex = movedItem.data('original_row_index');
let movedColumnIndex = movedItem.data('original_column_index');
if (oldContainerIndex !== -1 && movedColumnIndex !== undefined && movedColumnIndex !== -1) {
movedItem.data('original_row_index', -1);
movedItem.data('original_column_index', -1);
// 새 행의 인덱스를 찾습니다
let newContainerIndex = $('.dashboard-root').children('.dashboard-container').index($newRow);
// 이전 행에서 열 데이터를 추출합니다
let movedColumnData = this.layoutData.children[oldContainerIndex].children.splice(movedColumnIndex, 1)[0];
// 아이템이 드롭된 새 열 인덱스를 찾습니다
let newColumnIndex = movedItem.closest('.dashboard-container-children').children('.dashboard-chart').index(movedItem);
// 이동된 열 데이터를 새 행의 열에 새 인덱스에 삽입합니다
// 아이템이 마지막 위치로 이동된 경우 인덱스는 -1이 될 것이므로 이 경우를 처리합니다
if (newColumnIndex === -1) {
this.layoutData.children[newContainerIndex].children.push(movedColumnData);
} else {
this.layoutData.children[newContainerIndex].children.splice(newColumnIndex, 0, movedColumnData);
}
} else {
console.log('called twice on update');
}
}
showContainerEditor(isNew) {
if (isNew) {
$('.add-container').show();
$('.save-container').hide();
} else {
$('.add-container').hide();
$('.save-container').show();
}
let flatModel = LayoutUtil.flattenObject(JSON.parse(JSON.stringify(this.tempContainer)));
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
if ($(element).attr('type') === 'checkbox') {
$(element).prop('checked', false);
}
$(element).val('');
});
_.forEach(flatModel, (value, key) => {
if ($('#container_editor_modal').find(`input[name="${key}"]`).attr('type') === 'checkbox') {
$('#container_editor_modal').find(`input[name="${key}"]`).prop('checked', value);
} else {
$('#container_editor_modal').find(`input[name="${key}"], select[name="${key}"], textarea[name="${key}"]`).val(value);
}
});
$('#container_editor_modal').modal('show');
}
resize() {
console.log('resize');
this.dashboard.resizeAllCharts();
}
editDashboard(event) {
this.showDashboardEditor();
}
showDashboardEditor() {
let flatModel = LayoutUtil.flattenObject(JSON.parse(JSON.stringify(this.layoutData, LayoutUtil.echartReplacer)));
$('#dashboard_editor_modal').find('input, select, textarea').each((index, element) => {
if ($(element).attr('type') === 'checkbox') {
$(element).prop('checked', false);
}
$(element).val('');
});
_.forEach(flatModel, (value, key) => {
if ($('#dashboard_editor_modal').find(`input[name="${key}"]`).attr('type') === 'checkbox') {
$('#dashboard_editor_modal').find(`input[name="${key}"]`).prop('checked', value);
} else {
$('#dashboard_editor_modal').find(`input[name="${key}"], select[name="${key}"], textarea[name="${key}"]`).val(value);
}
});
$('#dashboard_editor_modal').modal('show');
}
saveDashboard(event) {
$('#dashboard_editor_modal').find('input, select, textarea').each((index, element) => {
const fieldName = $(element).attr('name');
const newValue = LayoutUtil.getFieldValue(element);
if (fieldName !== '') {
_.set(this.layoutData, fieldName, newValue);
}
});
console.log(this.layoutData);
this.resetLayout();
$('#dashboard_editor_modal').modal('hide');
}
bindEvents() {
$('.add-new-container').off('click').on('click', (event) => {
this.addNewContainer(event);
});
$('.edit-dashboard').off('click').on('click', (event) => {
this.editDashboard(event);
});
$('.save-dashboard').off('click').on('click', (event) => {
this.saveDashboard(event);
});
$('.remove-container').off('click').on('click', (event) => {
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
this.removeContainer(containerIndex);
});
$('.add-container').off('click').on('click', (event) => {
this.addContainer(event);
});
$('.save-container').off('click').on('click', (event) => {
this.saveContainer(event);
});
$('.edit-container').off('click').on('click', (event) => {
let editContainerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
this.editContainer(editContainerIndex);
});
$('.edit-chart').off('click').on('click', (event) => {
let editContainerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
let column = event.target.closest('.dashboard-chart');
let editColumnIndex = $(event.target.closest('.dashboard-container-children')).children('.dashboard-chart').index(column);
this.editChart(editContainerIndex, editColumnIndex);
});
$('.add-new-chart').off('click').on('click', (event) => {
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
this.addNewChart(containerIndex);
});
$('.remove-chart').off('click').on('click', (event) => {
let column = event.target.closest('.dashboard-chart');
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
let columnIndex = $(event.target.closest('.dashboard-container-children')).children('.dashboard-chart').index(column);
this.removeChart(containerIndex, columnIndex);
});
if ($('.dashboard-root').hasClass('ui-sortable')) {
$('.dashboard-root').sortable('destroy');
}
$('.dashboard-root').sortable({
handle: '.handle',
items: '.dashboard-container',
placeholder: 'ui-state-highlight',
start: (event, ui) => {
let originalRowIdx = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
ui.item.data('original_row_index', originalRowIdx);
}, update: (event, ui) => {
this.onRowsSorted(ui);
}
});
if ($('.dashboard-container').hasClass('ui-sortable')) {
$('.dashboard-container').sortable('destroy');
}
$('.dashboard-container-children').sortable({
// handle: ".handle",
placeholder: 'ui-state-highlight',
items: '.dashboard-chart',
connectWith: '.dashboard-container-children',
update: (event, ui) => {
this.onColumnsSorted(ui);
},
start: (event, ui) => {
let originalRowIdx = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
let originalColumnIdx = ui.item.closest('.dashboard-container-children').children('.dashboard-chart').index(ui.item);
ui.item.data('original_row_index', originalRowIdx);
ui.item.data('original_column_index', originalColumnIdx);
}
}).disableSelection();
}
}
+3 -2
View File
@@ -8,7 +8,7 @@
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
* - 페이징: #apiPagination (window.API_SELECTOR_PAGE_SIZE 건/페이지, 기본 15) — 카테고리/검색은 클라이언트에서 처리
*
* design(figma s2) 인라인 스크립트 대비 패치 4건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
@@ -22,7 +22,8 @@ document.addEventListener('DOMContentLoaded', function() {
return; // 모듈 미사용 페이지
}
const PAGE_SIZE = 12;
const configuredPageSize = Number(globalThis.API_SELECTOR_PAGE_SIZE);
const PAGE_SIZE = Number.isInteger(configuredPageSize) && configuredPageSize > 0 ? configuredPageSize : 15;
// DOM Elements
const searchInput = document.getElementById('apiSearch');
@@ -160,10 +160,14 @@
const cards = visible.map(function (incident) {
const timeline = (incident.recentTimeline || []).map(function (entry) {
// 지난 이슈 이력과 같은 기준: 진행 상태(labelKo)를 상태 색으로 구분해 보여준다.
const state = entry.stateAfter || 'NONE';
const label = entry.labelKo || '진행 상황';
const who = entry.authorType === 'SYSTEM' ? 'SYSTEM' : '운영자';
const whoClass = entry.authorType === 'SYSTEM' ? ' is-system' : '';
return '<div class="as-tl-row">'
+ '<span class="as-tl-time">' + escapeHtml(formatTime(entry.eventAt)) + '</span>'
+ '<span class="as-tl-state state-' + escapeHtml(state) + '">' + escapeHtml(label) + '</span>'
+ '<span class="as-tl-who' + whoClass + '">' + escapeHtml(who) + '</span>'
+ '<span>' + escapeHtml(entry.body) + '</span>'
+ '</div>';
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
Copyright (c) JS Foundation and other contributors
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,605 @@
/**
* 비밀번호 전송암호화 (RSA-OAEP + AES-GCM 하이브리드).
*
* 브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막는다.
* 서버 공개키로 AES-256-GCM 키를 감싸고, 비밀번호는 그 AES 키로 암호화해 아래 봉투로 보낸다.
*
* ENC1.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(암호문+태그)>
*
* 서버(PasswordDecryptFilter)가 파라미터 값을 평문으로 되돌리므로 컨트롤러는 아무것도 몰라도 된다.
*
* 암호화 엔진은 두 가지이며 봉투 형식은 동일하다.
* - subtle : secure context(HTTPS·localhost)에서 브라우저 Web Crypto 사용. 추가 다운로드 없음.
* - forge : 원격 오리진 HTTP 처럼 crypto.subtle 이 없는 환경에서 순수 JS 구현 사용.
* lib/forge-crypto.min.js 를 그때만 동적으로 내려받는다
* (window.__PASSWORD_CRYPTO__.softwareFallback 로 끌 수 있다).
*
* 폴백: 설정이 꺼져 있거나, 두 엔진 모두 쓸 수 없거나, 키 조회·암호화 중 오류가 나면 아무 일도 하지 않고
* 평문 그대로 전송한다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다. 서버 정책이 ENFORCE 면 그 평문이
* 거부되어 인증 실패로 이어진다.
*
* 주의: 이 조치는 XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있고,
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. 그래서 HTTP 접속에는 경고 팝업을 띄운다.
*/
(function (global) {
'use strict';
var cfg = global.__PASSWORD_CRYPTO__ || {};
var PREFIX = 'ENC1.';
/** 봉투를 만들 수 있는 환경에서 전송로 경고를 세션당 한 번만 띄우기 위한 sessionStorage 키 */
var WARN_KEY = 'portal.passwordCrypto.insecureWarned';
// ---------- 엔진 판정 ----------
/**
* Web Crypto 사용 가능 여부. 브라우저는 secure context 가 아니면 crypto.subtle 자체를 노출하지 않는다.
* 반면 crypto.getRandomValues 는 비 secure context 에서도 쓸 수 있어 forge 경로의 난수원으로 쓴다.
*/
function hasSubtle() {
return !!(global.isSecureContext && global.crypto && global.crypto.subtle);
}
function hasForge() {
return !!(cfg.softwareFallback && cfg.forgeUrl && global.document);
}
/** 설정·브라우저 조건상 봉투를 만들 수 있는가. 실제 forge 스크립트 로드는 사용 시점에 한다. */
function available() {
return !!(cfg.enabled
&& cfg.keyUrl
&& global.crypto
&& global.crypto.getRandomValues
&& global.fetch
&& global.Promise
&& (hasSubtle() || hasForge()));
}
// ---------- 인코딩 유틸 ----------
function bytesToBinary(bytes) {
var binary = '';
for (var i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return binary;
}
function binaryToBase64Url(binary) {
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function toBase64Url(buffer) {
return binaryToBase64Url(bytesToBinary(new Uint8Array(buffer)));
}
function base64ToBytes(base64) {
var binary = global.atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/** forge 는 바이트를 binary string 으로 다룬다. 난수는 항상 브라우저 CSPRNG 에서 받는다. */
function randomBinary(length) {
return bytesToBinary(global.crypto.getRandomValues(new Uint8Array(length)));
}
// ---------- 엔진 구현 ----------
/**
* 엔진 계약:
* importKey(base64Spki) -> Promise<publicKey>
* seal(publicKey, keyId, names, values) -> Promise<{이름: 봉투}>
* 두 엔진의 봉투 형식·알고리즘 파라미터는 동일해야 한다. 서버(PasswordEnvelopeCodec)는
* RSA-OAEP 의 MGF1 해시까지 SHA-256 으로 고정돼 있으므로 forge 쪽도 mgf1 을 명시해야 한다.
*/
var subtleEngine = {
name: 'subtle',
importKey: function (base64Spki) {
return global.crypto.subtle.importKey(
'spki',
base64ToBytes(base64Spki),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
},
seal: function (publicKey, keyId, names, values) {
var subtle = global.crypto.subtle;
var context = {};
return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt'])
.then(function (aesKey) {
context.aesKey = aesKey;
return subtle.exportKey('raw', aesKey);
})
.then(function (rawAesKey) {
return subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, rawAesKey);
})
.then(function (wrapped) {
var wrappedKey = toBase64Url(wrapped);
return global.Promise.all(names.map(function (name) {
var iv = global.crypto.getRandomValues(new Uint8Array(12));
return subtle.encrypt(
{ name: 'AES-GCM', iv: iv, tagLength: 128 },
context.aesKey,
new TextEncoder().encode(values[name])
).then(function (cipherText) {
return {
name: name,
envelope: PREFIX + keyId + '.' + wrappedKey
+ '.' + toBase64Url(iv) + '.' + toBase64Url(cipherText)
};
});
}));
})
.then(collectEnvelopes);
}
};
var forgeEngine = {
name: 'forge',
importKey: function (base64Spki) {
return loadForge().then(function (forge) {
var der = forge.util.createBuffer(global.atob(base64Spki));
return forge.pki.publicKeyFromAsn1(forge.asn1.fromDer(der));
});
},
seal: function (publicKey, keyId, names, values) {
return loadForge().then(function (forge) {
var aesKey = randomBinary(32);
// forge 의 OAEP 시드도 브라우저 CSPRNG 로 준다. forge 내장 PRNG 를 쓰지 않기 위함이다.
var wrappedKey = binaryToBase64Url(publicKey.encrypt(aesKey, 'RSA-OAEP', {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
seed: randomBinary(32)
}));
return names.map(function (name) {
var iv = randomBinary(12);
var cipher = forge.cipher.createCipher('AES-GCM', aesKey);
cipher.start({ iv: iv, tagLength: 128 });
cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(values[name])));
cipher.finish();
// 서버는 Java 관례대로 "암호문 + 태그" 가 이어 붙은 형태를 기대한다.
var payload = cipher.output.getBytes() + cipher.mode.tag.getBytes();
return {
name: name,
envelope: PREFIX + keyId + '.' + wrappedKey
+ '.' + binaryToBase64Url(iv) + '.' + binaryToBase64Url(payload)
};
});
}).then(collectEnvelopes);
}
};
function collectEnvelopes(results) {
var out = {};
results.forEach(function (result) {
out[result.name] = result.envelope;
});
return out;
}
/** 현재 환경에서 쓸 엔진. 페이지 수명 동안 바뀌지 않는다. */
function engine() {
if (hasSubtle()) {
return subtleEngine;
}
return hasForge() ? forgeEngine : null;
}
// ---------- forge 동적 로드 ----------
var forgeLoading = null;
/**
* forge 번들은 crypto.subtle 이 없을 때만 내려받는다. HTTPS 운영 환경에서는 전송 바이트가 0 이다.
* 한 번 시작한 로드는 재사용하고, 실패하면 호출자가 평문 폴백으로 처리한다.
*/
function loadForge() {
if (global.forge && global.forge.pki && global.forge.cipher) {
return global.Promise.resolve(global.forge);
}
if (forgeLoading) {
return forgeLoading;
}
forgeLoading = new global.Promise(function (resolve, reject) {
var script = global.document.createElement('script');
script.src = cfg.forgeUrl;
script.async = true;
script.onload = function () {
if (global.forge && global.forge.pki && global.forge.cipher) {
resolve(global.forge);
} else {
reject(new Error('forge 번들이 전역을 노출하지 않음'));
}
};
script.onerror = function () {
reject(new Error('forge 번들 로드 실패: ' + cfg.forgeUrl));
};
global.document.head.appendChild(script);
});
return forgeLoading;
}
// ---------- 키 조회 ----------
/** 재사용 가능한 스코프(SESSION/SERVER)에서만 채워진다. { keyId, publicKey, expiresAt } */
var cachedKey = null;
/**
* 서버에서 공개키를 받아 현재 엔진 형식으로 import 한다.
*
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
* 생성하게 만들지 않기 위함이다.
*/
function loadKey(currentEngine) {
if (cachedKey && cachedKey.expiresAt > Date.now()) {
return global.Promise.resolve(cachedKey);
}
return global.fetch(cfg.keyUrl, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: { 'Accept': 'application/json' }
}).then(function (response) {
if (!response.ok) {
throw new Error('공개키 조회 실패: HTTP ' + response.status);
}
return response.json();
}).then(function (data) {
if (!data || !data.enabled || !data.keyId || !data.publicKey) {
throw new Error('전송암호화 비활성 상태의 응답');
}
return currentEngine.importKey(data.publicKey).then(function (publicKey) {
var key = { keyId: data.keyId, publicKey: publicKey };
if (data.keyScope && data.keyScope !== 'REQUEST') {
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
var ttlMs = Math.max(0, (data.expiresIn || 0) - 30) * 1000;
if (ttlMs > 0) {
key.expiresAt = Date.now() + ttlMs;
cachedKey = key;
}
}
return key;
});
});
}
// ---------- 암호화 ----------
/**
* 값 여러 개를 한 번에 암호화한다. AES 키와 RSA 랩은 1회만 하고 IV 만 값마다 새로 만든다.
* @param {Object} values 이름 → 평문
* @returns {Promise<Object>} 이름 → 봉투 문자열
*/
function encryptValues(values) {
var names = Object.keys(values || {}).filter(function (name) {
var v = values[name];
return typeof v === 'string' && v.length > 0 && v.indexOf(PREFIX) !== 0;
});
var currentEngine = available() ? engine() : null;
if (!currentEngine || names.length === 0) {
return global.Promise.resolve(values || {});
}
return loadKey(currentEngine).then(function (key) {
return currentEngine.seal(key.publicKey, key.keyId, names, values);
}).then(function (envelopes) {
var out = {};
Object.keys(values).forEach(function (name) { out[name] = values[name]; });
Object.keys(envelopes).forEach(function (name) { out[name] = envelopes[name]; });
return out;
}).catch(function (error) {
// 암호화 실패는 화면을 막지 않는다. 서버 정책이 ENFORCE 가 아니면 평문으로 처리된다.
if (global.console && global.console.warn) {
global.console.warn('[password-crypto] 평문으로 폴백:', error && error.message);
}
return values;
});
}
/** 원본 입력에서 name 을 잠시 떼어둔 동안 그 이름을 보관하는 표시용 속성 */
var NAME_HOLDER = 'data-password-crypto-name';
/**
* 입력 요소에서 실제 <input> 하나를 고른다.
* 같은 name 이 둘 이상이면 form.elements[name] 이 RadioNodeList 를 돌려주므로,
* 우리가 붙인 hidden 이 아닌 실제 입력창을 골라야 한다.
*/
function resolveInput(el) {
if (!el) {
return null;
}
if (el.tagName) {
return el;
}
for (var i = 0; i < el.length; i++) {
var candidate = el[i];
if (candidate && candidate.tagName && candidate.type !== 'hidden') {
return candidate;
}
}
return null;
}
/**
* 원본 입력의 value 는 그대로 두고, name 만 같은 이름의 hidden 으로 옮겨 봉투를 싣는다.
* @returns {Function} 원상복구 함수
*/
function swapToEnvelopeField(form, input, name, envelope) {
var hidden = (form.ownerDocument || global.document).createElement('input');
hidden.type = 'hidden';
hidden.name = name;
hidden.value = envelope;
hidden.setAttribute(NAME_HOLDER, name);
input.setAttribute(NAME_HOLDER, name);
input.removeAttribute('name');
form.appendChild(hidden);
return function restore() {
if (hidden.parentNode) {
hidden.parentNode.removeChild(hidden);
}
if (input.getAttribute(NAME_HOLDER) === name) {
input.setAttribute('name', name);
input.removeAttribute(NAME_HOLDER);
}
};
}
/**
* 제출 직후 원상복구한다. 폼 데이터 직렬화는 form.submit()/requestSubmit() 호출 시점에
* 동기로 끝나므로, 다음 매크로태스크에서 되돌려도 봉투는 이미 전송된 뒤다.
* 뒤로가기(bfcache) 복원 시에도 name 이 떨어진 채 남지 않도록 pageshow 에서 한 번 더 처리한다.
*/
function scheduleRestore(restores) {
if (restores.length === 0) {
return;
}
var done = false;
var run = function () {
if (done) {
return;
}
done = true;
restores.forEach(function (restore) { restore(); });
global.removeEventListener('pageshow', run);
};
global.addEventListener('pageshow', run);
global.setTimeout(run, 0);
}
/**
* 폼의 지정 필드를 봉투로 전송한다. 실패해도 reject 하지 않고 평문을 남긴다.
*
* 입력창(<input type="password">)의 value 는 절대 건드리지 않는다. 브라우저 비밀번호 관리자는
* 제출 시점의 입력값을 그대로 저장하므로 봉투로 덮어쓰면 봉투 문자열이 비밀번호로 저장되고,
* 다음 자동완성 로그인이 깨진다. 대신 원본 입력의 name 을 잠시 떼고 같은 name 의 hidden 에
* 봉투를 실어 보낸 뒤 제출 직후 되돌린다.
*
* @param {HTMLFormElement} form
* @param {string[]} fieldNames
* @returns {Promise<void>}
*/
function encryptForm(form, fieldNames) {
if (!form || !fieldNames || fieldNames.length === 0 || !available()) {
return global.Promise.resolve();
}
var targets = {};
var elements = {};
fieldNames.forEach(function (name) {
var el = resolveInput(form.elements ? form.elements[name] : null);
if (el && typeof el.value === 'string' && el.value.length > 0) {
elements[name] = el;
targets[name] = el.value;
}
});
if (Object.keys(targets).length === 0) {
return global.Promise.resolve();
}
return encryptValues(targets).then(function (encrypted) {
var restores = [];
Object.keys(elements).forEach(function (name) {
// 폴백으로 평문이 그대로 돌아온 경우엔 아무것도 바꾸지 않는다.
if (encrypted[name] && encrypted[name] !== targets[name]) {
restores.push(swapToEnvelopeField(form, elements[name], name, encrypted[name]));
}
});
scheduleRestore(restores);
});
}
// ---------- 전송로 경고 ----------
/**
* 팝업을 띄운다. custom-popups.js 는 `const customPopups`(전역 렉시컬)로 노출돼
* window 프로퍼티가 아니므로 식별자로 직접 확인한다. 팝업 프래그먼트가 없는 화면에서는
* 브라우저 기본 alert 로 떨어진다.
*/
function showDialog(message) {
try {
if (typeof customPopups !== 'undefined'
&& customPopups && typeof customPopups.showAlert === 'function'
&& global.document.getElementById('customAlert')) {
customPopups.showAlert(message);
return;
}
} catch (e) {
// customPopups 미정의 등 — 아래 기본 alert 로 떨어진다.
}
global.alert(message.replace(/<br\s*\/?>/gi, '\n'));
}
/** 알림 팝업이 지금 떠 있는가. custom-popups.js 는 #customAlert 를 show()/hide() 로 토글한다. */
function isAlertOpen() {
var el = global.document.getElementById('customAlert');
return !!(el && el.style.display !== 'none');
}
/**
* 이미 떠 있는 알림이 닫힌 뒤에 띄운다.
*
* 알림 팝업은 페이지 전체가 #customAlert 하나를 돌려쓰고 showAlert() 는 그 안의 메시지를 덮어쓴다.
* 우리 경고는 load 시점이라 화면 자신의 메시지(DOMContentLoaded 에서 뜨는 로그인 실패 안내 등)보다
* 늦게 실행되므로, 그냥 부르면 그 메시지를 지워버린다. 먼저 뜬 쪽을 존중하고 뒤에 선다.
*
* hideAlert() 가 300ms 애니메이션 뒤에 display 를 내리므로 폴링으로 확인한다.
* 사용자가 계속 닫지 않으면 1분 뒤 포기한다(경고를 못 봐도 화면을 방해하지는 않는다).
*/
function showDialogQueued(message) {
if (!isAlertOpen()) {
showDialog(message);
return;
}
var waited = 0;
var timer = global.setInterval(function () {
waited += 200;
if (!isAlertOpen()) {
global.clearInterval(timer);
showDialog(message);
} else if (waited >= 60000) {
global.clearInterval(timer);
}
}, 200);
}
/**
* HTTPS 가 아닌 연결에서 비밀번호를 입력하려는 화면에 경고를 띄운다.
*
* 정책이 NONE 이면 아무것도 하지 않는다. 문구와 노출 빈도는 이 브라우저가 봉투를 만들 수 있는지로 갈린다.
*
* <ul>
* <li>봉투를 만들 수 있음 - 정보성 경고다. 값은 암호화되어 나가고, 남는 위험은 중간자가 이 스크립트
* 자체를 바꿔치기하는 경우뿐이다. 매번 띄우면 방해만 되므로 <b>세션당 1회</b>.</li>
* <li>봉투를 만들 수 없음 - 비밀번호가 평문으로 나가거나(permissive), 서버가 거부해 로그인 자체가
* 안 된다(enforce). 사용자가 놓치면 안 되는 상태이므로 <b>화면을 열 때마다</b>.</li>
* </ul>
*/
function warnInsecureTransport() {
if (!cfg.enabled || !cfg.policy || cfg.policy === 'NONE') {
return;
}
if (!global.location || global.location.protocol === 'https:') {
return;
}
if (!global.document.querySelector('input[type="password"]')) {
return;
}
var encryptable = available();
if (encryptable && !markWarnedOnce()) {
return;
}
var message;
if (encryptable) {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>로 접속했습니다.<br>'
+ '비밀번호는 전송 전에 암호화되지만, 중간자 공격까지 막지는 못합니다.<br>'
+ '운영 환경에서는 HTTPS 로 접속하세요.';
} else if (cfg.policy === 'ENFORCE') {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
+ '서버가 암호화되지 않은 비밀번호를 거부하므로 로그인할 수 없습니다.<br>'
+ '관리자에게 문의하세요.';
} else {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
+ '비밀번호가 <strong>암호화되지 않은 상태로</strong> 전송됩니다.';
}
showDialogQueued(message);
}
/**
* 이번 탭 세션에서 아직 경고를 안 띄웠으면 표시를 남기고 true 를 준다.
* 프라이빗 모드 등으로 sessionStorage 가 막혀 있으면 true — 안 띄우는 쪽보다 매번 띄우는 쪽이 안전하다.
*/
function markWarnedOnce() {
try {
if (!global.sessionStorage) {
return true;
}
if (global.sessionStorage.getItem(WARN_KEY) === '1') {
return false;
}
global.sessionStorage.setItem(WARN_KEY, '1');
} catch (e) {
// 접근 자체가 막힌 경우. 매번 띄운다.
}
return true;
}
// ---------- 선언적 훅 ----------
/**
* <form data-encrypt-fields="password,confirmPassword"> 를 만나면 submit 을 가로채
* 암호화 후 다시 제출한다. form.submit() 을 직접 호출하는 화면(로그인 등)은
* submit 이벤트가 발생하지 않으므로 encryptForm 을 명시적으로 호출해야 한다.
*/
function bindDeclarativeForms() {
if (!available()) {
return;
}
var forms = global.document.querySelectorAll('form[data-encrypt-fields]');
Array.prototype.forEach.call(forms, function (form) {
form.addEventListener('submit', function (event) {
if (form.getAttribute('data-encrypt-done') === 'true') {
form.removeAttribute('data-encrypt-done');
return;
}
// 페이지의 다른 submit 핸들러가 이미 제출을 막았다면(검증 실패 등)
// 여기서 재제출하면 그 검증을 우회하게 된다.
if (event.defaultPrevented) {
return;
}
var names = (form.getAttribute('data-encrypt-fields') || '')
.split(',')
.map(function (name) { return name.trim(); })
.filter(function (name) { return name.length > 0; });
if (names.length === 0) {
return;
}
event.preventDefault();
encryptForm(form, names).then(function () {
form.setAttribute('data-encrypt-done', 'true');
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
});
});
});
}
function start() {
bindDeclarativeForms();
warnInsecureTransport();
}
// DOMContentLoaded 가 아니라 load 시점에 건다. 페이지의 검증 핸들러는 대부분
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
// 경고 팝업도 같은 시점이어야 custom-popups.js 와 팝업 프래그먼트가 준비된 뒤에 뜬다.
if (global.document) {
if (global.document.readyState === 'complete') {
start();
} else {
global.addEventListener('load', start);
}
}
global.portalPasswordCrypto = {
available: available,
engine: function () {
var current = available() ? engine() : null;
return current ? current.name : null;
},
encryptForm: encryptForm,
encryptValues: encryptValues
};
})(window);
@@ -91,7 +91,10 @@
var seg = document.getElementById('tfaSegment');
seg.innerHTML = '';
self._maskedByType = {};
var channels = info.channels || [];
// SMS를 먼저 표시하고 첫 번째 수단을 기본 선택한다.
var channels = (info.channels || []).slice().sort(function (a, b) {
return (a.type === 'SMS' ? 0 : 1) - (b.type === 'SMS' ? 0 : 1);
});
channels.forEach(function (ch, idx) {
self._maskedByType[ch.type] = ch.masked;
var btn = document.createElement('button');
File diff suppressed because one or more lines are too long
@@ -1,173 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* AUTO-GENERATED FILE. DO NOT MODIFY.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports);
} else {
// Browser globals
factory({});
}
})(this, function(exports) {
/**
* Language: English.
*/
var localeObj = {
time: {
month: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
monthAbbr: [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
],
dayOfWeek: [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
],
dayOfWeekAbbr: [
'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
]
},
legend: {
selector: {
all: 'All',
inverse: 'Inv'
}
},
toolbox: {
brush: {
title: {
rect: 'Box Select',
polygon: 'Lasso Select',
lineX: 'Horizontally Select',
lineY: 'Vertically Select',
keep: 'Keep Selections',
clear: 'Clear Selections'
}
},
dataView: {
title: 'Data View',
lang: ['Data View', 'Close', 'Refresh']
},
dataZoom: {
title: {
zoom: 'Zoom',
back: 'Zoom Reset'
}
},
magicType: {
title: {
line: 'Switch to Line Chart',
bar: 'Switch to Bar Chart',
stack: 'Stack',
tiled: 'Tile'
}
},
restore: {
title: 'Restore'
},
saveAsImage: {
title: 'Save as Image',
lang: ['Right Click to Save Image']
}
},
series: {
typeNames: {
pie: 'Pie chart',
bar: 'Bar chart',
line: 'Line chart',
scatter: 'Scatter plot',
effectScatter: 'Ripple scatter plot',
radar: 'Radar chart',
tree: 'Tree',
treemap: 'Treemap',
boxplot: 'Boxplot',
candlestick: 'Candlestick',
k: 'K line chart',
heatmap: 'Heat map',
map: 'Map',
parallel: 'Parallel coordinate map',
lines: 'Line graph',
graph: 'Relationship graph',
sankey: 'Sankey diagram',
funnel: 'Funnel chart',
gauge: 'Gauge',
pictorialBar: 'Pictorial bar',
themeRiver: 'Theme River Map',
sunburst: 'Sunburst'
}
},
aria: {
general: {
withTitle: 'This is a chart about "{title}"',
withoutTitle: 'This is a chart'
},
series: {
single: {
prefix: '',
withName: ' with type {seriesType} named {seriesName}.',
withoutName: ' with type {seriesType}.'
},
multiple: {
prefix: '. It consists of {seriesCount} series count.',
withName: ' The {seriesId} series is a {seriesType} representing {seriesName}.',
withoutName: ' The {seriesId} series is a {seriesType}.',
separator: {
middle: '',
end: ''
}
}
},
data: {
allData: 'The data is as follows: ',
partialData: 'The first {displayCnt} items are: ',
withName: 'the data for {name} is {value}',
withoutName: '{value}',
separator: {
middle: ', ',
end: '. '
}
}
}
};
for (var key in localeObj) {
if (localeObj.hasOwnProperty(key)) {
exports[key] = localeObj[key];
}
}
});
@@ -1,169 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* AUTO-GENERATED FILE. DO NOT MODIFY.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
/**
* Language: English.
*/
var localeObj = {
time: {
month: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
],
monthAbbr: [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
],
dayOfWeek: [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
],
dayOfWeekAbbr: [
'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
]
},
legend: {
selector: {
all: 'All',
inverse: 'Inv'
}
},
toolbox: {
brush: {
title: {
rect: 'Box Select',
polygon: 'Lasso Select',
lineX: 'Horizontally Select',
lineY: 'Vertically Select',
keep: 'Keep Selections',
clear: 'Clear Selections'
}
},
dataView: {
title: 'Data View',
lang: ['Data View', 'Close', 'Refresh']
},
dataZoom: {
title: {
zoom: 'Zoom',
back: 'Zoom Reset'
}
},
magicType: {
title: {
line: 'Switch to Line Chart',
bar: 'Switch to Bar Chart',
stack: 'Stack',
tiled: 'Tile'
}
},
restore: {
title: 'Restore'
},
saveAsImage: {
title: 'Save as Image',
lang: ['Right Click to Save Image']
}
},
series: {
typeNames: {
pie: 'Pie chart',
bar: 'Bar chart',
line: 'Line chart',
scatter: 'Scatter plot',
effectScatter: 'Ripple scatter plot',
radar: 'Radar chart',
tree: 'Tree',
treemap: 'Treemap',
boxplot: 'Boxplot',
candlestick: 'Candlestick',
k: 'K line chart',
heatmap: 'Heat map',
map: 'Map',
parallel: 'Parallel coordinate map',
lines: 'Line graph',
graph: 'Relationship graph',
sankey: 'Sankey diagram',
funnel: 'Funnel chart',
gauge: 'Gauge',
pictorialBar: 'Pictorial bar',
themeRiver: 'Theme River Map',
sunburst: 'Sunburst'
}
},
aria: {
general: {
withTitle: 'This is a chart about "{title}"',
withoutTitle: 'This is a chart'
},
series: {
single: {
prefix: '',
withName: ' with type {seriesType} named {seriesName}.',
withoutName: ' with type {seriesType}.'
},
multiple: {
prefix: '. It consists of {seriesCount} series count.',
withName: ' The {seriesId} series is a {seriesType} representing {seriesName}.',
withoutName: ' The {seriesId} series is a {seriesType}.',
separator: {
middle: '',
end: ''
}
}
},
data: {
allData: 'The data is as follows: ',
partialData: 'The first {displayCnt} items are: ',
withName: 'the data for {name} is {value}',
withoutName: '{value}',
separator: {
middle: ', ',
end: '. '
}
}
}
};
echarts.registerLocale('EN', localeObj);
});
@@ -1,173 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* AUTO-GENERATED FILE. DO NOT MODIFY.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports);
} else {
// Browser globals
factory({});
}
})(this, function(exports) {
/**
* Language: Korean.
*/
var localeObj = {
time: {
month: [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
],
monthAbbr: [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
],
dayOfWeek: [
'일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'
],
dayOfWeekAbbr: [
'일', '월', '화', '수', '목', '금', '토'
]
},
legend: {
selector: {
all: '모두 선택',
inverse: '선택 범위 반전'
}
},
toolbox: {
brush: {
title: {
rect: '사각형 선택',
polygon: '올가미 선택',
lineX: '수평 선택',
lineY: '수직 선택',
keep: '선택 유지',
clear: '선택 지우기'
}
},
dataView: {
title: '날짜 보기',
lang: ['날짜 보기', '닫기', '새로 고침']
},
dataZoom: {
title: {
zoom: '확대/축소',
back: '확대/축소 초기화'
}
},
magicType: {
title: {
line: '꺽은선 그래프로 변경',
bar: '막대 그래프로 변경',
stack: '스택',
tiled: '타일'
}
},
restore: {
title: '복구'
},
saveAsImage: {
title: '이미지로 저장',
lang: ['이미지를 저장하려면 마우스 오른쪽 버튼을 클릭하세요.']
}
},
series: {
typeNames: {
pie: '원 그래프',
bar: '막대 그래프',
line: '꺽은선 그래프',
scatter: '산점도',
effectScatter: '물결 효과 산점도',
radar: '방사형 그래프',
tree: '트리',
treemap: '트리맵',
boxplot: '상자 수염 그래프',
candlestick: '캔들스틱 차트',
k: 'K 라인 차트',
heatmap: '히트 맵',
map: '지도',
parallel: '평행 좌표 맵',
lines: '선',
graph: '관계 그래프',
sankey: '산키 다이어그램',
funnel: '깔때기형 그래프',
gauge: '계기',
pictorialBar: '픽토그램 차트',
themeRiver: '스트림 그래프',
sunburst: '선버스트 차트'
}
},
aria: {
general: {
withTitle: '"{title}"에 대한 차트입니다.',
withoutTitle: '차트입니다.'
},
series: {
single: {
prefix: '',
withName: ' 차트 유형은 {seriesType}이며 {seriesName}을 표시합니다.',
withoutName: ' 차트 유형은 {seriesType}입니다.'
},
multiple: {
prefix: '. {seriesCount} 하나의 차트 시리즈로 구성됩니다.',
withName: ' {seriesId}번째 시리즈는 {seriesName}을 나타내는 {seriesType} representing.',
withoutName: ' {seriesId}번째 시리즈는 {seriesType}입니다.',
separator: {
middle: '',
end: ''
}
}
},
data: {
allData: '데이터: ',
partialData: '첫번째 {displayCnt} 아이템: ',
withName: '{name}의 데이터는 {value}',
withoutName: '{value}',
separator: {
middle: ', ',
end: '. '
}
}
}
};
for (var key in localeObj) {
if (localeObj.hasOwnProperty(key)) {
exports[key] = localeObj[key];
}
}
});
@@ -1,169 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* AUTO-GENERATED FILE. DO NOT MODIFY.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
/**
* Language: Korean.
*/
var localeObj = {
time: {
month: [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
],
monthAbbr: [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
],
dayOfWeek: [
'일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'
],
dayOfWeekAbbr: [
'일', '월', '화', '수', '목', '금', '토'
]
},
legend: {
selector: {
all: '모두 선택',
inverse: '선택 범위 반전'
}
},
toolbox: {
brush: {
title: {
rect: '사각형 선택',
polygon: '올가미 선택',
lineX: '수평 선택',
lineY: '수직 선택',
keep: '선택 유지',
clear: '선택 지우기'
}
},
dataView: {
title: '날짜 보기',
lang: ['날짜 보기', '닫기', '새로 고침']
},
dataZoom: {
title: {
zoom: '확대/축소',
back: '확대/축소 초기화'
}
},
magicType: {
title: {
line: '꺽은선 그래프로 변경',
bar: '막대 그래프로 변경',
stack: '스택',
tiled: '타일'
}
},
restore: {
title: '복구'
},
saveAsImage: {
title: '이미지로 저장',
lang: ['이미지를 저장하려면 마우스 오른쪽 버튼을 클릭하세요.']
}
},
series: {
typeNames: {
pie: '원 그래프',
bar: '막대 그래프',
line: '꺽은선 그래프',
scatter: '산점도',
effectScatter: '물결 효과 산점도',
radar: '방사형 그래프',
tree: '트리',
treemap: '트리맵',
boxplot: '상자 수염 그래프',
candlestick: '캔들스틱 차트',
k: 'K 라인 차트',
heatmap: '히트 맵',
map: '지도',
parallel: '평행 좌표 맵',
lines: '선',
graph: '관계 그래프',
sankey: '산키 다이어그램',
funnel: '깔때기형 그래프',
gauge: '계기',
pictorialBar: '픽토그램 차트',
themeRiver: '스트림 그래프',
sunburst: '선버스트 차트'
}
},
aria: {
general: {
withTitle: '"{title}"에 대한 차트입니다.',
withoutTitle: '차트입니다.'
},
series: {
single: {
prefix: '',
withName: ' 차트 유형은 {seriesType}이며 {seriesName}을 표시합니다.',
withoutName: ' 차트 유형은 {seriesType}입니다.'
},
multiple: {
prefix: '. {seriesCount} 하나의 차트 시리즈로 구성됩니다.',
withName: ' {seriesId}번째 시리즈는 {seriesName}을 나타내는 {seriesType} representing.',
withoutName: ' {seriesId}번째 시리즈는 {seriesType}입니다.',
separator: {
middle: '',
end: ''
}
}
},
data: {
allData: '데이터: ',
partialData: '첫번째 {displayCnt} 아이템: ',
withName: '{name}의 데이터는 {value}',
withoutName: '{value}',
separator: {
middle: ', ',
end: '. '
}
}
}
};
echarts.registerLocale('KO', localeObj);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#f2385a',
'#f5a503',
'#4ad9d9',
'#f7879c',
'#c1d7a8',
'#4dffd2',
'#fccfd7',
'#d5f6f6'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#f2385a'
}
},
visualMap: {
color: ['#f2385a', '#f5a503']
},
toolbox: {
color: ['#f2385a', '#f2385a', '#f2385a', '#f2385a']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#f2385a',
type: 'dashed'
},
crossStyle: {
color: '#f2385a'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#f2385a' // Handle color
},
timeline: {
lineStyle: {
color: '#f2385a'
},
controlStyle: {
color: '#f2385a',
borderColor: '#f2385a'
}
},
candlestick: {
itemStyle: {
color: '#f2385a',
color0: '#f5a503'
},
lineStyle: {
width: 1,
color: '#f2385a',
color0: '#f5a503'
},
areaStyle: {
color: '#c1d7a8',
color0: '#4ad9d9'
}
},
map: {
itemStyle: {
color: '#f2385a'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#f2385a'
},
linkStyle: {
color: '#f2385a'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#f5a503'],
[0.8, '#f2385a'],
[1, '#c1d7a8']
],
width: 8
}
}
}
};
echarts.registerTheme('azul', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#001727',
'#805500',
'#ffff00',
'#ffd11a',
'#f2d71f',
'#f2be19',
'#f3a81a',
'#fff5cc'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#001727'
}
},
visualMap: {
color: ['#001727', '#805500']
},
toolbox: {
color: ['#001727', '#001727', '#001727', '#001727']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#001727',
type: 'dashed'
},
crossStyle: {
color: '#001727'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#001727' // Handle color
},
timeline: {
lineStyle: {
color: '#001727'
},
controlStyle: {
color: '#001727',
borderColor: '#001727'
}
},
candlestick: {
itemStyle: {
color: '#f3a81a',
color0: '#ffff00'
},
lineStyle: {
width: 1,
color: '#ffff00',
color0: '#f3a81a'
},
areaStyle: {
color: '#805500',
color0: '#ffff00'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#f3a81a',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#805500'
}
},
map: {
itemStyle: {
color: '#ffd11a'
},
areaStyle: {
color: '#f2be19'
},
label: {
color: '#ffd11a'
}
},
graph: {
itemStyle: {
color: '#001727'
},
linkStyle: {
color: '#001727'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#f2d71f'],
[0.8, '#001727'],
[1, '#ffff00']
],
width: 8
}
}
}
};
echarts.registerTheme('bee-inspired', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#1790cf',
'#1bb2d8',
'#99d2dd',
'#88b0bb',
'#1c7099',
'#038cc4',
'#75abd0',
'#afd6dd'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#1790cf'
}
},
visualMap: {
color: ['#1790cf', '#a2d4e6']
},
toolbox: {
color: ['#1790cf', '#1790cf', '#1790cf', '#1790cf']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#1790cf',
type: 'dashed'
},
crossStyle: {
color: '#1790cf'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#1790cf' // Handle color
},
timeline: {
lineStyle: {
color: '#1790cfa'
},
controlStyle: {
color: '#1790cf',
borderColor: '#1790cf'
}
},
candlestick: {
itemStyle: {
color: '#1bb2d8',
color0: '#99d2dd'
},
lineStyle: {
width: 1,
color: '#1c7099',
color0: '#88b0bb'
},
areaStyle: {
color: '#1790cf',
color0: '#1bb2d8'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#1bb2d8',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#1790cf'
}
},
graph: {
itemStyle: {
color: '#1bb2d8'
},
linkStyle: {
color: '#88b0bb'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '99d2dd'
},
label: {
color: '#c12e34'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#1bb2d8'],
[0.8, '#1790cf'],
[1, '#1c7099']
],
width: 8
}
}
}
};
echarts.registerTheme('blue', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#fad089',
'#ff9c5b',
'#f5634a',
'#ed303c',
'#3b8183',
'#f7826e',
'#faac9e',
'#fcd5cf'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#fad089'
}
},
visualMap: {
color: ['#fad089', '#a2d4e6']
},
toolbox: {
color: ['#fad089', '#fad089', '#fad089', '#fad089']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#fad089',
type: 'dashed'
},
crossStyle: {
color: '#fad089'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#fad089' // Handle color
},
timeline: {
lineStyle: {
color: '#fad089'
},
controlStyle: {
color: '#fad089',
borderColor: '#fad089'
}
},
candlestick: {
itemStyle: {
color: '#ff9c5b',
color0: '#f5634a'
},
lineStyle: {
width: 1,
color: '#3b8183',
color0: '#ed303c'
},
areaStyle: {
color: '#fad089',
color0: '#ed303c'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#fad089',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#ed303c'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#f5634a'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#f5634a'
},
linkStyle: {
color: '#fad089'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ff9c5b'],
[0.8, '#fad089'],
[1, '#3b8183']
],
width: 8
}
}
}
};
echarts.registerTheme('caravan', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#f0d8A8',
'#3d1c00',
'#86b8b1',
'#f2d694',
'#fa2a00',
'#ff8066',
'#ffd5cc',
'#f9edd2'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#f0d8A8'
}
},
visualMap: {
color: ['#f0d8A8', '#3d1c00']
},
toolbox: {
color: ['#f0d8A8', '#f0d8A8', '#f0d8A8', '#f0d8A8']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#f0d8A8',
type: 'dashed'
},
crossStyle: {
color: '#f0d8A8'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#f0d8A8' // Handle color
},
timeline: {
lineStyle: {
color: '#f0dba8'
},
controlStyle: {
color: '#f0dba8',
borderColor: '#f0dba8'
}
},
candlestick: {
itemStyle: {
color: '#3d1c00',
color0: '#86b8b1'
},
lineStyle: {
width: 1,
color: '#fa2a00',
color0: '#f2d694'
},
areaStyle: {
color: '#f0d8A8',
color0: '#86b8b1'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#86b8b1'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#3d1c00'
},
linkStyle: {
color: '#f0d8A8'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#3d1c00'],
[0.8, '#f0d8A8'],
[1, '#fa2a00']
],
width: 8
}
}
}
};
echarts.registerTheme('carp', theme);
});
@@ -1,180 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#b21ab4',
'#6f0099',
'#2a2073',
'#0b5ea8',
'#17aecc',
'#b3b3ff',
'#eb99ff',
'#fae6ff',
'#e6f2ff',
'#eeeeee'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#00aecd'
}
},
visualMap: {
color: ['#00aecd', '#a2d4e6']
},
toolbox: {
color: ['#00aecd', '#00aecd', '#00aecd', '#00aecd']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#00aecd',
type: 'dashed'
},
crossStyle: {
color: '#00aecd'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#00aecd' // Handle color
},
timeline: {
lineStyle: {
color: '#00aecd'
},
controlStyle: {
color: '#00aecd',
borderColor: '00aecd'
}
},
candlestick: {
itemStyle: {
color: '#00aecd',
color0: '#a2d4e6'
},
lineStyle: {
width: 1,
color: '#00aecd',
color0: '#a2d4e6'
},
areaStyle: {
color: '#b21ab4',
color0: '#0b5ea8'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#b21ab4',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#0b5ea8'
}
},
graph: {
itemStyle: {
color: '#b21ab4'
},
linkStyle: {
color: '#2a2073'
}
},
map: {
itemStyle: {
color: '#c12e34'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#dddddd'],
[0.8, '#00aecd'],
[1, '#f5ccff']
],
width: 8
}
}
}
};
echarts.registerTheme('cool', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#00305a',
'#004b8d',
'#0074d9',
'#4192d9',
'#7abaf2',
'#99cce6',
'#d6ebf5',
'#eeeeee'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#00305a' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#004b8d'],
[0.8, '#00305a'],
[1, '#7abaf2']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-blue', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#458c6b',
'#f2da87',
'#d9a86c',
'#d94436',
'#a62424',
'#76bc9b',
'#cce6da',
'#eeeeee'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#458c6b' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#f2da87'],
[0.8, '#458c6b'],
[1, '#a62424']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-bold', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#52656b',
'#ff3b77',
'#a3cc00',
'#ffffff',
'#b8b89f',
'#ffccdb',
'#e5ff80',
'#f4f4f0'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#52656b' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ff3b77'],
[0.8, '#52656b'],
[1, '#b8b89f']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-digerati', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#00a8c6',
'#40c0cb',
'#ebd3ad',
'#aee239',
'#8fbe00',
'#33e0ff',
'#b3f4ff',
'#e6ff99'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#00a8c6' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#40c0cb'],
[0.8, '#00a8c6'],
[1, '#8fbe00']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-fresh-cut', theme);
});
@@ -1,164 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#eee';
var axisCommon = function() {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
axisTick: {
lineStyle: {
color: contrastColor
}
},
axisLabel: {
color: contrastColor
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#aaa'
}
},
splitArea: {
areaStyle: {
color: contrastColor
}
}
};
};
var colorPalette = [
'#cc0e00',
'#ff1a0a',
'#ff8880',
'#ffc180',
'#ffc2b0',
'#ffffff',
'#ff8880',
'#ffe6e6'
];
var theme = {
color: colorPalette,
backgroundColor: '#333',
tooltip: {
axisPointer: {
lineStyle: {
color: contrastColor
},
crossStyle: {
color: contrastColor
}
}
},
legend: {
textStyle: {
color: contrastColor
}
},
title: {
textStyle: {
color: contrastColor
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#cc0e00' // Handle color
},
timeline: {
itemStyle: {
color: colorPalette[1]
},
lineStyle: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
},
label: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ff1a0a'],
[0.8, '#cc0e00'],
[1, '#ffc2b0']
],
width: 8
}
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark-mushroom', theme);
});
@@ -1,224 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var contrastColor = '#B9B8CE';
var backgroundColor = '#100C2A';
var axisCommon = function () {
return {
axisLine: {
lineStyle: {
color: contrastColor
}
},
splitLine: {
lineStyle: {
color: '#484753'
}
},
splitArea: {
areaStyle: {
color: ['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']
}
},
minorSplitLine: {
lineStyle: {
color: '#20203B'
}
}
};
};
var colorPalette = [
'#4992ff',
'#7cffb2',
'#fddd60',
'#ff6e76',
'#58d9f9',
'#05c091',
'#ff8a45',
'#8d48e3',
'#dd79ff'
];
var theme = {
darkMode: true,
color: colorPalette,
backgroundColor: backgroundColor,
axisPointer: {
lineStyle: {
color: '#817f91'
},
crossStyle: {
color: '#817f91'
},
label: {
// TODO Contrast of label backgorundColor
color: '#fff'
}
},
legend: {
textStyle: {
color: contrastColor
}
},
textStyle: {
color: contrastColor
},
title: {
textStyle: {
color: '#EEF1FA'
},
subtextStyle: {
color: '#B9B8CE'
}
},
toolbox: {
iconStyle: {
borderColor: contrastColor
}
},
dataZoom: {
borderColor: '#71708A',
textStyle: {
color: contrastColor
},
brushStyle: {
color: 'rgba(135,163,206,0.3)'
},
handleStyle: {
color: '#353450',
borderColor: '#C5CBE3'
},
moveHandleStyle: {
color: '#B0B6C3',
opacity: 0.3
},
fillerColor: 'rgba(135,163,206,0.2)',
emphasis: {
handleStyle: {
borderColor: '#91B7F2',
color: '#4D587D'
},
moveHandleStyle: {
color: '#636D9A',
opacity: 0.7
}
},
dataBackground: {
lineStyle: {
color: '#71708A',
width: 1
},
areaStyle: {
color: '#71708A'
}
},
selectedDataBackground: {
lineStyle: {
color: '#87A3CE'
},
areaStyle: {
color: '#87A3CE'
}
}
},
visualMap: {
textStyle: {
color: contrastColor
}
},
timeline: {
lineStyle: {
color: contrastColor
},
label: {
color: contrastColor
},
controlStyle: {
color: contrastColor,
borderColor: contrastColor
}
},
calendar: {
itemStyle: {
color: backgroundColor
},
dayLabel: {
color: contrastColor
},
monthLabel: {
color: contrastColor
},
yearLabel: {
color: contrastColor
}
},
timeAxis: axisCommon(),
logAxis: axisCommon(),
valueAxis: axisCommon(),
categoryAxis: axisCommon(),
line: {
symbol: 'circle'
},
graph: {
color: colorPalette
},
gauge: {
title: {
color: contrastColor
}
},
candlestick: {
itemStyle: {
color: '#FD1050',
color0: '#0CF49B',
borderColor: '#FD1050',
borderColor0: '#0CF49B'
}
}
};
theme.categoryAxis.splitLine.show = false;
echarts.registerTheme('dark', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#59535e',
'#e7dcef',
'#f1baf3',
'#5d4970',
'#372049',
'#c0b2cd',
'#ffccff',
'#f2f0f5'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#59535e'
}
},
visualMap: {
color: ['#59535e', '#e7dcef']
},
toolbox: {
color: ['#59535e', '#59535e', '#59535e', '#59535e']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#59535e',
type: 'dashed'
},
crossStyle: {
color: '#59535e'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#59535e' // Handle color
},
timeline: {
lineStyle: {
color: '#59535e'
},
controlStyle: {
color: '#59535e',
borderColor: '#59535e'
}
},
candlestick: {
itemStyle: {
color: '#e7dcef',
color0: '#f1baf3'
},
lineStyle: {
width: 1,
color: '#372049',
color0: '#5d4970'
},
areaStyle: {
color: '#59535e',
color0: '#e7dcef'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#59535e',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#e7dcef'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#f1baf3'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#59535e'
},
linkStyle: {
color: '#59535e'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#e7dcef'],
[0.8, '#59535e'],
[1, '#372049']
],
width: 8
}
}
}
};
echarts.registerTheme('eduardo', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#313b23',
'#494f2b',
'#606233',
'#d6b77b',
'#0e0e0e',
'#076278',
'#808080',
'#e7d5b1'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#313b23'
}
},
visualMap: {
color: ['#313b23', '#494f2b']
},
toolbox: {
color: ['#313b23', '#313b23', '#313b23', '#313b23']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#313b23',
type: 'dashed'
},
crossStyle: {
color: '#313b23'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#313b23' // Handle color
},
timeline: {
lineStyle: {
color: '#313b23'
},
controlStyle: {
color: '#313b23',
borderColor: '#313b23'
}
},
candlestick: {
itemStyle: {
color: '#494f2b',
color0: '#606233'
},
lineStyle: {
width: 1,
color: '#0e0e0e',
color0: '#d6b77b'
},
areaStyle: {
color: '#494f2b',
color0: '#d6b77b'
}
},
map: {
itemStyle: {
color: '#606233'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#494f2b'
},
linkStyle: {
color: '#313b23'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#494f2b'],
[0.8, '#313b23'],
[1, '0e0e0e']
],
width: 8
}
}
}
};
echarts.registerTheme('forest', theme);
});
@@ -1,163 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#00a8c6',
'#40c0cb',
'#f0dec2',
'#aee239',
'#8fbe00',
'#33e0ff',
'#b3f4ff',
'#e6ff99'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#00a8c6'
}
},
visualMap: {
color: ['#00a8c6', '#a2d4e6']
},
toolbox: {
color: ['#00a8c6', '#00a8c6', '#00a8c6', '#00a8c6']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#00a8c6',
type: 'dashed'
},
crossStyle: {
color: '#00a8c6'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(144,197,237,0.2)', // Fill the color
handleColor: '#00a8c6' // Handle color
},
timeline: {
lineStyle: {
color: '#00a8c6'
},
controlStyle: {
color: '#00a8c6',
borderColor: '#00a8c6'
}
},
candlestick: {
itemStyle: {
color: '#40c0cb',
color0: '#f0dec2'
},
lineStyle: {
width: 1,
color: '#8fbe00',
color0: '#aee239'
},
areaStyle: {
color: '#00a8c6',
color0: '#aee239'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#f0dec2'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#f0dec2'
},
linkStyle: {
color: '#00a8c6'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#40c0cb'],
[0.8, '#00a8c6'],
[1, '#8fbe00']
],
width: 8
}
}
}
};
echarts.registerTheme('fresh-cut', theme);
});
@@ -1,178 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#ffcb6a',
'#ffa850',
'#ffe2c4',
'#e5834e',
'#ffb081',
'#f7826e',
'#faac9e',
'#fcd5cf'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#ffcb6a'
}
},
visualMap: {
color: ['#ffcb6a', '#ffa850']
},
toolbox: {
color: ['#ffcb6a', '#ffcb6a', '#ffcb6a', '#ffcb6a']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#ffcb6a',
type: 'dashed'
},
crossStyle: {
color: '#ffcb6a'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
handleColor: '#ffcb6a' // Handle color
},
timeline: {
lineStyle: {
color: '#ffcb6a'
},
controlStyle: {
color: '#ffcb6a',
borderColor: '#ffcb6a'
}
},
candlestick: {
itemStyle: {
color: '#ffa850',
color0: '#ffe2c4'
},
lineStyle: {
width: 1,
color: '#ffb081',
color0: '#e5834e'
},
areaStyle: {
color: '#e5834e',
color0: '#fcd5cf'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#fcd5cf',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#e5834e'
}
},
map: {
itemStyle: {
color: '#ffe2c4'
},
areaStyle: {
color: '#ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#f2385a'
},
linkStyle: {
color: '#ffcb6a'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#ffa850'],
[0.8, '#ffcb6a'],
[1, '#ffb081']
],
width: 8
}
}
}
};
echarts.registerTheme('fruit', theme);
});
@@ -1,220 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#757575',
'#c7c7c7',
'#dadada',
'#8b8b8b',
'#b5b5b5',
'#e9e9e9'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#757575'
}
},
dataRange: {
color: ['#636363', '#dcdcdc']
},
toolbox: {
color: ['#757575', '#757575', '#757575', '#757575']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#757575',
type: 'dashed'
},
crossStyle: {
color: '#757575'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(117,117,117,0.2)', // Fill the color
handleColor: '#757575' // Handle color
},
grid: {
borderWidth: 0
},
categoryAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#757575'
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
valueAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#757575'
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
timeline: {
lineStyle: {
color: '#757575'
},
controlStyle: {
color: '#757575',
borderColor: '#757575'
}
},
candlestick: {
itemStyle: {
color: '#8b8b8b',
color0: '#dadada'
},
lineStyle: {
width: 1,
color: '#757575',
color0: '#c7c7c7'
},
areaStyle: {
color: '#757575',
color0: '#e9e9e9'
}
},
map: {
itemStyle: {
color: '#c7c7c7'
},
areaStyle: {
color: 'ddd'
},
label: {
color: '#c12e34'
}
},
graph: {
itemStyle: {
color: '#e9e9e9'
},
linkStyle: {
color: '#757575'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#e9e9e9',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#757575'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#b5b5b5'],
[0.8, '#757575'],
[1, '#5c5c5c']
],
width: 8
}
}
}
};
echarts.registerTheme('gray', theme);
});
@@ -1,222 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['exports', 'echarts'], factory);
} else if (
typeof exports === 'object' &&
typeof exports.nodeName !== 'string'
) {
// CommonJS
factory(exports, require('echarts/lib/echarts'));
} else {
// Browser globals
factory({}, root.echarts);
}
})(this, function(exports, echarts) {
var log = function(msg) {
if (typeof console !== 'undefined') {
console && console.error && console.error(msg);
}
};
if (!echarts) {
log('ECharts is not Loaded');
return;
}
var colorPalette = [
'#408829',
'#68a54a',
'#a9cba2',
'#86b379',
'#397b29',
'#8abb6f',
'#759c6a',
'#bfd3b7'
];
var theme = {
color: colorPalette,
title: {
textStyle: {
fontWeight: 'normal',
color: '#408829'
}
},
visualMap: {
color: ['408829', '#a9cba2']
},
toolbox: {
color: ['#408829', '#408829', '#408829', '#408829']
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.5)',
axisPointer: {
// Axis indicator, coordinate trigger effective
type: 'line', // The default is a straight line 'line' | 'shadow'
lineStyle: {
// Straight line indicator style settings
color: '#408829',
type: 'dashed'
},
crossStyle: {
color: '#408829'
},
shadowStyle: {
// Shadow indicator style settings
color: 'rgba(200,200,200,0.3)'
}
}
},
// Area scaling controller
dataZoom: {
dataBackgroundColor: '#eee', // Data background color
fillerColor: 'rgba(64,136,41,0.2)', // Fill the color
handleColor: '#408829' // Handle color
},
grid: {
borderWidth: 0
},
categoryAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#408829'
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
valueAxis: {
axisLine: {
// Coordinate axis
lineStyle: {
// Property 'lineStyle' controls line styles
color: '#408829'
}
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
}
},
splitLine: {
// Separation line
lineStyle: {
// Property 'lineStyle' (see lineStyle) controls line styles
color: ['#eee']
}
}
},
timeline: {
lineStyle: {
color: '#408829'
},
controlStyle: {
color: '#408829',
borderColor: '#408829'
}
},
candlestick: {
itemStyle: {
color: '#68a54a',
color0: '#a9cba2'
},
lineStyle: {
width: 1,
color: '#408829',
color0: '#86b379'
},
areaStyle: {
color: '#408829',
color0: '#bfd3b7'
}
},
graph: {
itemStyle: {
color: '#bfd3b7'
},
linkStyle: {
color: '#408829'
}
},
chord: {
padding: 4,
itemStyle: {
color: '#bfd3b7',
borderWidth: 1,
borderColor: 'rgba(128, 128, 128, 0.5)'
},
lineStyle: {
color: 'rgba(128, 128, 128, 0.5)'
},
areaStyle: {
color: '#408829'
}
},
map: {
itemStyle: {
color: '#ddd'
},
areaStyle: {
color: '#408829'
},
label: {
color: '#000'
}
},
gauge: {
axisLine: {
lineStyle: {
color: [
[0.2, '#86b379'],
[0.8, '#68a54a'],
[1, '#408829']
],
width: 8
}
}
}
};
echarts.registerTheme('green', theme);
});

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