23 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 59c379b22c 비밀번호 전송 암호화 기능 추가:
- RSA-OAEP + AES-GCM 기반 비밀번호 전송 암호화 모듈 구현
- 클라이언트 공개키 발급 및 복호화 처리 로직 개발
- 암호화 설정 속성 및 요청별/세션별/서버별 키 보관소 구성
2026-09-01 15:06:30 +09:00
108 changed files with 6225 additions and 551 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
}
}
}
+50 -6
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'
@@ -87,6 +88,10 @@ dependencies {
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'
@@ -190,13 +195,13 @@ ext {
// 2.18.x 마지막 패치를 쓴다.
set('jackson-bom.version', '2.18.10')
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
// org.thymeleaf.spring5 + javax.servlet 그대로다.
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
// 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.4.RELEASE')
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 혼재였음).
@@ -286,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 설정이 그대로 노출되어
@@ -315,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 ->
+128 -3
View File
@@ -58,11 +58,14 @@
<notes><![CDATA[
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
thymeleaf.version=3.1.4.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.4.RELEASE.jar 다.
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-3.1.4.RELEASE.jar, thymeleaf-spring5-3.1.4.RELEASE.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>
@@ -118,4 +121,126 @@
<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=\
@@ -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;
}
}
@@ -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,9 +1,7 @@
package com.eactive.apim.portal.common.exception;
import java.util.Arrays;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
@@ -195,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);
@@ -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",
@@ -2,6 +2,8 @@ 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;
@@ -64,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;
}
@@ -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;
}
@@ -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
+17 -4
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
@@ -37,3 +39,14 @@ server:
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
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
+76 -53
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;
@@ -27323,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;
@@ -27337,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;
@@ -27351,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;
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

+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');
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');
@@ -195,6 +195,11 @@
color: #212529;
font-size: 20px;
line-height: $line-height-normal;
// 한글은 기본값(normal)이면 음절 단위로 끊겨 "막지는 못합니 / 다" 처럼 잘린다.
// keep-all 로 어절(띄어쓰기) 단위 줄바꿈을 강제한다. 대신 공백 없는 긴 문자열(URL·키값)이
// 넘칠 수 있어 overflow-wrap 으로 그때만 강제 분리한다.
word-break: keep-all;
overflow-wrap: break-word;
p {
margin: 0;
@@ -118,6 +118,36 @@
color: #666;
}
.api-statistics-container .statistics-date-range {
width: 260px;
max-width: 100%;
cursor: pointer;
&:focus-visible {
outline: 2px solid #0049B4;
outline-offset: 2px;
border-radius: 6px;
}
}
// 공통 달력 CSS의 z-index: 0을 이 페이지의 팝업에만 보정한다.
.daterangepicker.statistics-date-picker {
z-index: 1100;
max-width: calc(100vw - 16px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
.applyBtn {
color: #fff;
background: #0049B4;
border-radius: 4px;
}
@media (max-width: 560px) {
max-height: 70vh;
overflow-y: auto;
}
}
.btn-search {
background: #0049B4;
border: none;
@@ -529,8 +529,6 @@
padding: 22px 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
&.is-ongoing { border-left-color: var(--as-warn); }
.as-maint-head {
display: flex;
align-items: baseline;
@@ -539,7 +537,7 @@
flex-wrap: wrap;
}
.as-maint-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--as-text); }
.as-maint-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--as-info); }
.as-schedule {
display: inline-flex;
@@ -554,8 +552,6 @@
font-variant-numeric: tabular-nums;
}
&.is-ongoing .as-schedule { color: var(--as-warn); background: var(--as-warn-bg); }
.as-maint-body { margin: 14px 0 12px; font-size: 14px; line-height: 1.6; color: var(--as-text-3); }
.as-maint-meta {
@@ -16,7 +16,9 @@
<div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2>
<form th:action="@{/auth/stepup/password}" method="post">
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
<form th:action="@{/auth/stepup/password}" method="post"
data-encrypt-fields="currentPassword">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
@@ -13,8 +13,9 @@
다시 서비스를 이용하시려면 <span>'장기미사용 제한 해제'</span>를 하셔야 이용하실 수 있습니다.</p>
</div>
<div class="form_type">
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
<form id="accountForm" role="form" name="accountForm" th:action="@{/dormant_account/verify}"
method="post">
method="post" data-encrypt-fields="password">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" id="mobileNumber" name="mobileNumber">
<!-- 아이디 입력 -->
@@ -233,7 +233,12 @@
customPopups.showAlert('입력 정보 확인');
} else {
refreshCsrfAndThen(form, function () {
form.submit();
// 비밀번호 전송암호화. 기능이 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면
// 아무 일도 하지 않고 평문 그대로 제출된다(실패해도 reject 하지 않는다).
// form.submit() 은 submit 이벤트를 발생시키지 않아 선언적 훅이 걸리지 않으므로
// 여기서 직접 호출한다.
window.portalPasswordCrypto.encryptForm(form, ['password'])
.then(function () { form.submit(); });
});
}
form.classList.add('was-validated');
@@ -20,7 +20,10 @@
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form id="passwordChangeForm" th:action="@{/password/change}" method="post">
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다(2FA 미요구 경로).
2FA 요구 경로는 아래 스크립트가 preventDefault 하므로 거기서 직접 암호화한다. -->
<form id="passwordChangeForm" th:action="@{/password/change}" method="post"
data-encrypt-fields="newPassword,confirmPassword">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
<div class="info-notice-box">
@@ -127,16 +130,19 @@
if (csrfToken && csrfHeader) {
headers[csrfHeader.content] = csrfToken.content;
}
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
window.portalPasswordCrypto.encryptValues({ password: pw }).then(function (enc) {
$.ajax({
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
method: 'POST',
headers: headers,
data: { password: pw }
data: { password: enc.password }
}).done(function (res) {
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
});
});
}, 300);
});
})();
@@ -160,11 +166,17 @@
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
}
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
// 전송암호화(봉투 치환) 후 제출. 기능이 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
// form.submit() 은 submit 이벤트를 재발생시키지 않아 선언적 훅이 걸리지 않으므로 직접 호출한다.
var encryptThenSubmit = function () {
window.portalPasswordCrypto.encryptForm(form, ['newPassword', 'confirmPassword'])
.then(function () { form.submit(); });
};
if (typeof TwoFactorAuth === 'undefined') { encryptThenSubmit(); return; }
TwoFactorAuth.open({
purpose: '/password/change',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onSuccess: encryptThenSubmit,
onCancel: function () { /* 사용자 취소 — 유지 */ }
});
});
@@ -16,7 +16,10 @@
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/password/verify}" method="post">
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다.
기능이 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 평문 그대로 간다. -->
<form th:action="@{/password/verify}" method="post"
data-encrypt-fields="currentPassword">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
@@ -390,11 +390,13 @@
return;
}
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
$.ajax({
url: /*[[@{/check_password}]]*/ '/check_password',
type: 'POST',
data: {
password: password,
password: enc.password,
mobileNumber: $('#mobileNumber').val(),
loginId: $('#loginId').val(),
_csrf: $('input[name="_csrf"]').val()
@@ -411,6 +413,7 @@
}
});
});
});
// 비밀번호 확인 검증
$('#confirmPassword').on('blur', function () {
@@ -419,12 +422,17 @@
return;
}
// 두 값이 같은 AES 키로 각각 다른 IV 로 암호화되므로 서버에서 복호화 후 비교해야 한다.
window.portalPasswordCrypto.encryptValues({
password: $('#password').val(),
confirmPassword: confirmPassword
}).then(function (enc) {
$.ajax({
url: /*[[@{/check_password_match}]]*/ '/check_password_match',
type: 'POST',
data: {
password: $('#password').val(),
confirmPassword: confirmPassword,
password: enc.password,
confirmPassword: enc.confirmPassword,
_csrf: $('input[name="_csrf"]').val()
},
success: function (response) {
@@ -440,6 +448,7 @@
});
});
});
});
</script>
</th:block>
@@ -377,10 +377,13 @@
return false;
}
// 사용하지 않도록 설정한 약관은 화면에 없으므로(관리 콘솔 > 약관 종류 관리) 없으면 통과 처리
const termsOfUseEl = document.getElementById('termsOfUse');
const privacyCollectEl = document.getElementById('privacyCollect');
const notificationConsentEl = document.getElementById('notificationConsent');
const validations = {
termsOfUse: $('#termsOfUse').prop('checked'),
privacyPolicy: $('#privacyCollect').prop('checked'),
termsOfUse: !termsOfUseEl || termsOfUseEl.checked,
privacyPolicy: !privacyCollectEl || privacyCollectEl.checked,
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
compRegNo: $('#compRegNo').val().trim() !== '',
corpRegNo: $('#corpRegNo').val().trim() !== '',
@@ -423,13 +426,15 @@
}
// 공통 항목
const termsOfUseEl = document.getElementById('termsOfUse');
const privacyCollectEl = document.getElementById('privacyCollect');
const notificationConsentEl = document.getElementById('notificationConsent');
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
if (termsOfUseEl && !termsOfUseEl.checked) errors.push('이용약관에 동의해주세요.');
if (privacyCollectEl && !privacyCollectEl.checked) errors.push('개인정보 수집·이용에 동의해주세요.');
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
return errors;
@@ -466,7 +471,10 @@
console.log('Form validation passed, collecting form data...');
const form = collectFormData();
document.body.appendChild(form);
form.submit();
// 전송암호화(봉투 치환) 후 제출. 시나리오별로 password 가 없을 수 있으나
// 존재하는 필드만 치환하므로 그대로 호출한다.
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
.then(function () { form.submit(); });
} else {
console.log('Form validation failed');
@@ -11,7 +11,14 @@
</div>
<form id="agreementForm" class="agreement-form">
<!-- Agree All Checkbox -->
<!--/*
'전체 동의' 허용 여부(관리 콘솔 > 약관 종류 관리)에 따라 두 가지 방식으로 동작한다.
- 허용 : '전체 동의' 체크박스를 노출하고, 항목별 스크롤 없이 바로 동의할 수 있다
- 미허용 : '전체 동의' 체크박스가 없고, 각 항목을 펼쳐 끝까지 읽어야 동의가 활성화된다
agreeAllEnabled 를 내려주지 않는 화면은 미허용(스크롤 동의)으로 동작한다.
*/-->
<!-- Agree All Checkbox : '전체 동의'를 허용할 때만 노출 -->
<th:block th:if="${agreeAllEnabled != null and agreeAllEnabled}">
<div class="agreement-all-section">
<label class="agreement-checkbox-label">
<input type="checkbox" id="agree_all" class="agreement-checkbox-input">
@@ -22,14 +29,16 @@
<!-- Divider -->
<div class="agreement-divider"></div>
</th:block>
<!-- Individual Agreements -->
<div class="agreement-items-list">
<!-- Terms of Use -->
<!-- Terms of Use (사용하지 않도록 설정하면 숨김. 플래그가 없는 화면은 기존대로 노출) -->
<th:block th:if="${showTermsOfUse == null or showTermsOfUse}">
<div class="agreement-item-row">
<label class="agreement-checkbox-label">
<input type="checkbox" name="termsOfUse" id="termsOfUse" class="agreement-checkbox-input"
required disabled>
required th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">
<span class="agreement-checkbox-custom"></span>
<span class="agreement-checkbox-text">
<span class="agreement-required">[필수]</span>
@@ -46,15 +55,18 @@
</div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="termsOfUse" disabled>동의</button>
data-checkbox-id="termsOfUse" th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">동의</button>
</div>
</div>
<!-- Privacy Policy -->
</th:block>
<!-- Privacy Collect (사용하지 않도록 설정하면 숨김. 플래그가 없는 화면은 기존대로 노출) -->
<th:block th:if="${showPrivacyCollect == null or showPrivacyCollect}">
<div class="agreement-item-row">
<label class="agreement-checkbox-label">
<input type="checkbox" name="privacyCollect" id="privacyCollect"
class="agreement-checkbox-input" required disabled>
class="agreement-checkbox-input" required th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">
<span class="agreement-checkbox-custom"></span>
<th:block th:switch="${registrationType}">
<span class="agreement-checkbox-text" th:case="'personal'">
@@ -78,16 +90,18 @@
</div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="privacyCollect" disabled>동의</button>
data-checkbox-id="privacyCollect" th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">동의</button>
</div>
</div>
<!-- Notification Consent (모델에 notificationConsent가 있을 때만 노출) -->
<th:block th:if="${notificationConsent != null}">
</th:block>
<!-- Notification Consent (사용하지 않도록 설정했거나 내용이 없으면 숨김) -->
<th:block th:if="${(showNotificationConsent == null or showNotificationConsent) and notificationConsent != null}">
<div class="agreement-item-row">
<label class="agreement-checkbox-label">
<input type="checkbox" name="notificationConsent" id="notificationConsent"
class="agreement-checkbox-input" required disabled>
class="agreement-checkbox-input" required th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">
<span class="agreement-checkbox-custom"></span>
<span class="agreement-checkbox-text">
<span class="agreement-required">[필수]</span>
@@ -105,7 +119,7 @@
</div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="notificationConsent" disabled>동의</button>
data-checkbox-id="notificationConsent" th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">동의</button>
</div>
</div>
</th:block>
@@ -276,6 +290,12 @@
row.style.cursor = 'pointer';
row.addEventListener('click', function (e) {
if (e.target.closest('.agreement-checkbox-input') || e.target.closest('.agreement-checkbox-custom')) {
// 개별 체크박스도 전체동의와 동일하게, 아직 다 읽지 않았으면 경고 알림 표시
const checkbox = this.querySelector('.agreement-checkbox-input');
if (checkbox && checkbox.disabled) {
e.preventDefault();
customPopups.showAlert('약관을 상세히 펼쳐서 끝까지 스크롤하여 읽으신 후에 동의하실 수 있습니다.');
}
return;
}
const toggleIcon = this.querySelector('.agreement-toggle-icon');
@@ -122,8 +122,9 @@
isPasswordValid &&
isPasswordMatch &&
userNameElement.val().trim() !== '' &&
termsOfUseElement.prop('checked') &&
privacyCollectElement.prop('checked') &&
// 사용하지 않도록 설정한 약관은 화면에 없으므로(관리 콘솔 > 약관 종류 관리) 없으면 통과 처리
(termsOfUseElement.length === 0 || termsOfUseElement.prop('checked')) &&
(privacyCollectElement.length === 0 || privacyCollectElement.prop('checked')) &&
(notificationConsentElement.length === 0 || notificationConsentElement.prop('checked')) &&
mobileNumberElement.val().trim() !== '' &&
isAuthVerified;
@@ -185,7 +186,9 @@
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
form.appendChild(authCompletedField);
form.submit();
// 전송암호화(봉투 치환) 후 제출. 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
.then(function () { form.submit(); });
} else {
customPopups.showAlert(!isAuthVerified
? '인증번호확인을 완료해주세요.'
@@ -41,10 +41,11 @@
<div class="search-filter-row">
<!-- 일별: 날짜 범위 -->
<div class="date-range-picker" id="dailyFilter">
<input type="date" id="startDate" class="date-input"
<input type="text" id="statisticsDateRange" class="date-input statistics-date-range"
aria-label="일별 조회 기간" title="1년 전부터 오늘까지, 시작일·종료일을 포함해 최대 40일을 선택할 수 있습니다." readonly>
<input type="hidden" id="startDate"
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
<span class="date-separator">-</span>
<input type="date" id="endDate" class="date-input"
<input type="hidden" id="endDate"
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
</div>
@@ -57,7 +58,7 @@
</select>
</div>
<button type="button" class="btn-search" id="btnSearch">
<button type="button" class="btn-search" id="btnSearch" aria-label="통계 조회">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
@@ -335,6 +336,8 @@
let currentMode = 'DAILY';
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
const MAX_DATE_RANGE_DAYS = 40;
const MIN_QUERY_DATE = moment([[${ statsMinDate }]], 'YYYY-MM-DD', true);
const MAX_QUERY_DATE = moment([[${ statsMaxDate }]], 'YYYY-MM-DD', true);
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
function updateChart() {
@@ -460,16 +463,70 @@
return meta ? meta.getAttribute('content') : '';
}
// Validate date range (max 40 days)
// 서버 기준 1년 전 ~ 오늘 중 최대 40일만 조회한다 (양 끝 포함).
function validateDateRange(startDate, endDate) {
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
var start = new Date(startDate), end = new Date(endDate);
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
var start = moment(startDate, 'YYYY-MM-DD', true), end = moment(endDate, 'YYYY-MM-DD', true);
if (!start.isValid() || !end.isValid()) return { valid: false, message: '올바른 날짜를 선택해주세요.' };
if (start.isAfter(end)) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
if (start.isBefore(MIN_QUERY_DATE, 'day') || end.isAfter(MAX_QUERY_DATE, 'day')) {
return { valid: false, message: '조회 날짜는 1년 전부터 오늘까지만 선택할 수 있습니다.' };
}
if (end.diff(start, 'days') >= MAX_DATE_RANGE_DAYS) {
return { valid: false, message: '조회 기간은 시작일·종료일을 포함해 최대 ' + MAX_DATE_RANGE_DAYS + '일로 선택해주세요.' };
}
return { valid: true };
}
function initializeDateRangePicker() {
var input = $('#statisticsDateRange');
function updateRangeText() {
input.val(moment($('#startDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD') + ' - '
+ moment($('#endDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD'));
}
input.daterangepicker({
startDate: moment($('#startDate').val(), 'YYYY-MM-DD', true),
endDate: moment($('#endDate').val(), 'YYYY-MM-DD', true),
minDate: MIN_QUERY_DATE.clone(),
maxDate: MAX_QUERY_DATE.clone(),
maxSpan: { days: MAX_DATE_RANGE_DAYS - 1 },
autoUpdateInput: false,
opens: 'left',
drops: 'auto',
locale: {
format: 'YYYY.MM.DD',
separator: ' - ',
applyLabel: '조회',
cancelLabel: '취소',
daysOfWeek: ['일', '월', '화', '수', '목', '금', '토'],
monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
firstDay: 0
}
});
input.data('daterangepicker').container.addClass('statistics-date-picker');
updateRangeText();
input.on('show.daterangepicker', function (event, picker) {
// 조회하지 않고 바깥을 눌러 닫았다면 확정된 조회 조건으로 다시 연다.
picker.setStartDate(moment($('#startDate').val(), 'YYYY-MM-DD', true));
picker.setEndDate(moment($('#endDate').val(), 'YYYY-MM-DD', true));
picker.updateView();
});
input.on('apply.daterangepicker', function (event, picker) {
$('#startDate').val(picker.startDate.format('YYYY-MM-DD'));
$('#endDate').val(picker.endDate.format('YYYY-MM-DD'));
updateRangeText();
searchStatistics();
});
input.on('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
input.data('daterangepicker').show();
}
});
}
function buildPayload() {
return {
mode: currentMode,
@@ -505,8 +562,10 @@
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
}
if (p.startDate) $('#startDate').val(p.startDate);
if (p.endDate) $('#endDate').val(p.endDate);
if (validateDateRange(p.startDate, p.endDate).valid) {
$('#startDate').val(p.startDate);
$('#endDate').val(p.endDate);
}
if (p.month) $('#month').val(p.month);
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
return true;
@@ -563,12 +622,12 @@
// Event Handlers
$('#btnSearch').on('click', searchStatistics);
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
$('#startDate, #endDate').on('change', searchStatistics);
$('#month').on('change', searchStatistics);
$('#startDate, #endDate').on('keypress', function (e) { if (e.which === 13) searchStatistics(); });
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
if (restoreParams()) {
var restored = restoreParams();
initializeDateRangePicker();
if (restored) {
searchStatistics();
}
});
@@ -23,7 +23,7 @@
<div class="search-box">
<button type="button" class="btn btn-primary" id="addUserBtn">
<i class="fas fa-plus"></i>
<span>개발자 추가</span>
<span>개발자 초대</span>
</button>
</div>
</div>
@@ -96,7 +96,7 @@
th:if="${user.userStatus.toString() == 'PENDING'}"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedEmailAddr}">
초대취소
초대 취소
</button>
</div>
<!-- Action Dropdown (Mobile) -->
@@ -139,7 +139,7 @@
th:if="${user.userStatus.toString() == 'PENDING'}"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedEmailAddr}">
초대취소
초대 취소
</button>
</div>
</div>
@@ -163,7 +163,7 @@
<button type="button" class="list-table-btn list-table-btn--secondary cancel_invitation"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedMobileNumber}">
초대취소
초대 취소
</button>
</div>
<!-- Mobile Dropdown -->
@@ -180,7 +180,7 @@
<button type="button" class="dropdown-item cancel_invitation"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedMobileNumber}">
초대취소
초대 취소
</button>
</div>
</div>
@@ -294,7 +294,7 @@
});
}
// Add event listeners to the "개발자 추가" buttons
// Add event listeners to the "개발자 초대" buttons
const addUserBtn = document.getElementById('addUserBtn');
if (addUserBtn) {
@@ -166,10 +166,13 @@
function post(url, password) {
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
headers[CSRF_HEADER] = CSRF_TOKEN;
// 전송암호화. 꺼져 있거나 브라우저가 지원하지 못하면 원본 값이 그대로 돌아온다.
return window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
return fetch(url, {
method: 'POST',
headers: headers,
body: 'password=' + encodeURIComponent(password)
body: 'password=' + encodeURIComponent(enc.password)
});
}).then(function (r) { return r.json(); });
}
@@ -24,7 +24,7 @@
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
클라이언트에서 12건/페이지로 페이징(#apiPagination, api-selector.js PAGE_SIZE) — 전체선택/모달은 페이징과
PTL_PROPERTY(Portal / api.list.page-size, 기본 15) 기준으로 클라이언트 페이징 — 전체선택/모달은 페이징과
무관하게 필터된 전체 목록 기준으로 동작.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
*/-->
@@ -107,6 +107,7 @@
<script th:inline="javascript">
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
window.API_SELECTOR_PAGE_SIZE = /*[[${@apiListProperties.pageSize}]]*/ 15;
</script>
<script th:src="@{/js/api-selector.js}"></script>
</th:block>
@@ -32,6 +32,20 @@
};
</script>
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 봉투를 만들 수단이 전혀 없으면
모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다.
forgeUrl 은 crypto.subtle 을 못 쓰는 환경(원격 오리진 HTTP)에서만 동적으로 로드된다. -->
<script th:inline="javascript">
window.__PASSWORD_CRYPTO__ = {
enabled: /*[[${passwordCrypto.enabled}]]*/ false,
policy: /*[[${passwordCrypto.policy}]]*/ 'NONE',
softwareFallback: /*[[${passwordCrypto.softwareFallback}]]*/ true,
keyUrl: /*[[@{/api/security/password-key.json}]]*/ '/api/security/password-key.json',
forgeUrl: /*[[@{/js/lib/forge-crypto.min.js}]]*/ '/js/lib/forge-crypto.min.js'
};
</script>
<script th:src="@{/js/password-crypto.js}"></script>
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
@@ -5,33 +5,25 @@
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1 th:text="${isTermsOfUse ? '이용약관' : (isPrivacyCollect ? '개인정보수집동의서' : '알림 수신 동의서')}">이용 약관</h1>
<h1 th:text="${agreementTitle} ?: '약관'">이용 약관</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="terms-container">
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title" th:text="${isTermsOfUse ? '이용약관' : (isPrivacyCollect ? '개인정보수집동의서' : '알림 수신 동의서')}">이용약관</h2>
<h2 class="common-title" th:text="${agreementTitle} ?: '약관'">이용약관</h2>
</div>
<!-- Tab Navigation -->
<div class="terms-tabs">
<a th:href="@{/agreements/terms(tab='terms')}"
<!-- Tab Navigation : 노출 대상과 순서는 관리 콘솔 '약관 종류 관리'에서 설정한다 -->
<div class="terms-tabs" th:if="${not #lists.isEmpty(termsTabs)}">
<a th:each="termsTab : ${termsTabs}"
th:href="@{/agreements/terms(tab=${termsTab.tab})}"
class="tab-link"
th:classappend="${isTermsOfUse ? 'active' : ''}">
th:classappend="${termsTab.active ? 'active' : ''}"
th:text="${termsTab.title}">
이용약관
</a>
<a th:href="@{/agreements/terms(tab='privacy-collect')}"
class="tab-link"
th:classappend="${isPrivacyCollect ? 'active' : ''}">
개인정보수집동의서
</a>
<a th:href="@{/agreements/terms(tab='notification')}"
class="tab-link"
th:classappend="${isNotification ? 'active' : ''}">
알림 수신 동의서
</a>
</div>
<!-- Version Selector -->
@@ -9,7 +9,7 @@
<!-- Modal Header -->
<div class="modal-header">
<h3 class="modal-title">초대취소</h3>
<h3 class="modal-title">초대 취소</h3>
<button type="button" class="modal-close" id="cancelInvitationPopupCloseButton">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
@@ -24,8 +24,8 @@
<strong id="cancelInvitationTarget" style="color: #4B9BFF;"></strong>에 대한<br>초대를 취소하시겠습니까?
</p>
<!-- 알림 수신 동의 체크박스 -->
<div class="pop_consent_group" style="margin-top: 16px;">
<!-- 알림 수신 동의 체크박스 : '알림 수신 동의서'가 약관 페이지에 배치되어 있을 때만 노출 -->
<div class="pop_consent_group" style="margin-top: 16px;" th:if="${notificationConsentAvailable}">
<label class="pop_consent_label"
style="display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #475569; line-height: 1.5; cursor: pointer;">
<input type="checkbox" id="cancelInvitationNotifyConsent" style="margin-top: 3px; flex-shrink: 0;">
@@ -37,12 +37,20 @@
</span>
</label>
</div>
<!-- 알림 수신 동의서를 사용하지 않거나 약관 페이지에 배치하지 않은 경우 : 안내 문구만 노출 -->
<div class="pop_consent_group" style="margin-top: 16px;" th:unless="${notificationConsentAvailable}">
<p style="margin: 0; font-size: 13px; color: #475569; line-height: 1.5;">
현재 알림 수신 동의서를 운영하지 않아 수신자에게 초대 취소 알림 메시지가 발송되지 않습니다.
초대 취소는 정상적으로 진행됩니다.
</p>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-submit" id="cancelInvitationPopupCancelButton">닫기</button>
<button type="button" class="btn btn-primary btn-submit" id="cancelInvitationPopupConfirmButton">초대취소</button>
<button type="button" class="btn btn-primary btn-submit" id="cancelInvitationPopupConfirmButton">초대 취소</button>
</div>
</div>
@@ -9,7 +9,7 @@
<!-- Modal Header -->
<div class="modal-header">
<h3 class="modal-title">개발자 추가</h3>
<h3 class="modal-title">개발자 초대</h3>
<button type="button" class="modal-close" id="userInvitePopupCloseButton">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
@@ -21,7 +21,7 @@
<!-- Modal Body -->
<div class="modal-body">
<p id="userInvitePopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
추가할 개발자 휴대폰 번호를 입력해 주세요.
초대할 개발자 휴대폰 번호를 입력해 주세요.
</p>
<!-- Mobile Input Field -->
@@ -34,8 +34,8 @@
<div id="userInvitePopupError" class="error-message"></div>
</div>
<!-- 알림 수신 동의 체크박스 -->
<div class="pop_consent_group" style="margin-top: 16px;">
<!-- 알림 수신 동의 체크박스 : '알림 수신 동의서'가 약관 페이지에 배치되어 있을 때만 노출 -->
<div class="pop_consent_group" style="margin-top: 16px;" th:if="${notificationConsentAvailable}">
<label class="pop_consent_label"
style="display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #475569; line-height: 1.5; cursor: pointer;">
<input type="checkbox" id="userInviteNotifyConsent" style="margin-top: 3px; flex-shrink: 0;">
@@ -47,6 +47,14 @@
</span>
</label>
</div>
<!-- 알림 수신 동의서를 사용하지 않거나 약관 페이지에 배치하지 않은 경우 : 안내 문구만 노출 -->
<div class="pop_consent_group" style="margin-top: 16px;" th:unless="${notificationConsentAvailable}">
<p style="margin: 0; font-size: 13px; color: #475569; line-height: 1.5;">
현재 알림 수신 동의서를 운영하지 않아 수신자에게 초대 메시지가 발송되지 않습니다.
초대는 정상적으로 진행되며, 초대받은 개발자에게 직접 안내해 주세요.
</p>
</div>
</div>
<!-- Modal Footer -->
@@ -0,0 +1,242 @@
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.LocalDateTime;
import java.time.Month;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.Model;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 약관 페이지({@code /agreements/terms}) 탭 해석 검증.
*
* <p>노출 대상과 순서는 관리 콘솔의 '약관 종류 관리' 설정을 따르고,
* 기존 북마크 호환을 위해 {@code tab} 슬러그는 고정이다.
*/
@ExtendWith(MockitoExtension.class)
class AgreementsControllerTest {
@Mock
private AgreementsFacade agreementsFacade;
@Mock
private AgreementTypeConfigService agreementTypeConfigService;
@InjectMocks
private AgreementsController agreementsController;
private Model model;
@BeforeEach
void setUp() {
model = new ExtendedModelMap();
}
private AgreementsDTO agreement(String name, LocalDateTime publishedOn) {
AgreementsDTO dto = new AgreementsDTO();
dto.setName(name);
dto.setPublishedOn(publishedOn);
return dto;
}
@SuppressWarnings("unchecked")
private List<AgreementTabDTO> tabs() {
return (List<AgreementTabDTO>) model.getAttribute("termsTabs");
}
@Test
@DisplayName("tab=privacy 는 외부 개인정보처리방침으로 리다이렉트한다(북마크 호환)")
void showTerms_privacyRedirectsToExternalUrl() {
String view = agreementsController.showTerms("privacy", null, model);
assertEquals("redirect:" + AgreementsController.PRIVACY_POLICY_EXTERNAL_URL, view);
// 리다이렉트 경로에서는 설정 조회조차 하지 않는다
verify(agreementTypeConfigService, never()).getDisplayTypes();
verify(agreementsFacade, never()).getAgreementsList(org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("노출할 약관 종류가 없으면 빈 화면으로 방어한다")
void showTerms_noDisplayTypes() {
when(agreementTypeConfigService.getDisplayTypes()).thenReturn(Collections.emptyList());
String view = agreementsController.showTerms(null, "2026-01-01", model);
assertEquals(AgreementsController.TERMS_AGREEMENTS, view);
assertTrue(tabs().isEmpty());
assertTrue(((List<?>) model.getAttribute("agreementsList")).isEmpty());
assertNull(model.getAttribute("selectedAgreement"));
assertEquals("2026-01-01", model.getAttribute("selectedDate"));
assertEquals("약관", model.getAttribute("agreementTitle"));
assertNull(model.getAttribute("agreementType"));
assertNull(model.getAttribute("currentTab"));
verify(agreementsFacade, never()).getAgreementsList(org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("tab 이 없으면 노출 목록의 첫 번째 약관을 연다")
void showTerms_defaultsToFirstDisplayType() {
AgreementsDTO latest = agreement("이용약관 v2", LocalDateTime.of(2026, Month.MARCH, 1, 0, 0));
AgreementsDTO older = agreement("이용약관 v1", LocalDateTime.of(2025, Month.JANUARY, 1, 0, 0));
when(agreementTypeConfigService.getDisplayTypes()).thenReturn(Arrays.asList(
AgreementType.TERMS_OF_USE, AgreementType.NOTIFICATION_CONSENT));
when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name()))
.thenReturn(Arrays.asList(latest, older));
String view = agreementsController.showTerms(null, null, model);
assertEquals(AgreementsController.TERMS_AGREEMENTS, view);
assertEquals("terms", model.getAttribute("currentTab"));
assertEquals(AgreementType.TERMS_OF_USE.getCode(), model.getAttribute("agreementType"));
assertEquals(AgreementType.TERMS_OF_USE.getDescription(), model.getAttribute("agreementTitle"));
// publishedOn 미지정이면 목록의 첫 항목
assertSame(latest, model.getAttribute("selectedAgreement"));
List<AgreementTabDTO> tabs = tabs();
assertEquals(Arrays.asList("terms", "notification"),
tabs.stream().map(AgreementTabDTO::getTab).collect(Collectors.toList()));
assertTrue(tabs.get(0).isActive());
assertFalse(tabs.get(1).isActive());
}
@Test
@DisplayName("tab 슬러그가 노출 목록에 있으면 그 약관을 연다")
void showTerms_selectsRequestedTab() {
AgreementsDTO notification = agreement("알림 수신 동의서", LocalDateTime.of(2026, Month.FEBRUARY, 1, 0, 0));
when(agreementTypeConfigService.getDisplayTypes()).thenReturn(Arrays.asList(
AgreementType.TERMS_OF_USE, AgreementType.NOTIFICATION_CONSENT));
when(agreementsFacade.getAgreementsList(AgreementType.NOTIFICATION_CONSENT.name()))
.thenReturn(Collections.singletonList(notification));
String view = agreementsController.showTerms("notification", null, model);
assertEquals(AgreementsController.TERMS_AGREEMENTS, view);
assertEquals("notification", model.getAttribute("currentTab"));
assertEquals(AgreementType.NOTIFICATION_CONSENT.getCode(), model.getAttribute("agreementType"));
List<AgreementTabDTO> tabs = tabs();
assertFalse(tabs.get(0).isActive());
assertTrue(tabs.get(1).isActive());
}
@Test
@DisplayName("노출 목록에 없는 tab 은 첫 번째 탭으로 보정한다")
void showTerms_unknownTabFallsBackToFirst() {
AgreementsDTO privacyCollect = agreement("개인정보수집동의서", LocalDateTime.of(2026, Month.JANUARY, 5, 0, 0));
// notification 은 노출 대상이 아니다
when(agreementTypeConfigService.getDisplayTypes())
.thenReturn(Collections.singletonList(AgreementType.PRIVACY_COLLECT));
when(agreementsFacade.getAgreementsList(AgreementType.PRIVACY_COLLECT.name()))
.thenReturn(Collections.singletonList(privacyCollect));
String view = agreementsController.showTerms("notification", null, model);
assertEquals(AgreementsController.TERMS_AGREEMENTS, view);
assertEquals("privacy-collect", model.getAttribute("currentTab"));
assertEquals(AgreementType.PRIVACY_COLLECT.getCode(), model.getAttribute("agreementType"));
}
@Test
@DisplayName("빈 tab 문자열도 첫 번째 탭으로 보정한다")
void showTerms_emptyTabFallsBackToFirst() {
AgreementsDTO terms = agreement("이용약관", LocalDateTime.of(2026, Month.JANUARY, 1, 0, 0));
when(agreementTypeConfigService.getDisplayTypes())
.thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE));
when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name()))
.thenReturn(Collections.singletonList(terms));
String view = agreementsController.showTerms("", null, model);
assertEquals(AgreementsController.TERMS_AGREEMENTS, view);
assertEquals("terms", model.getAttribute("currentTab"));
}
@Test
@DisplayName("publishedOn 이 주어지면 해당 제정일의 개정본을 고른다")
void showTerms_picksAgreementByPublishedOn() {
AgreementsDTO latest = agreement("v2", LocalDateTime.of(2026, Month.MARCH, 1, 9, 30));
AgreementsDTO older = agreement("v1", LocalDateTime.of(2025, Month.JANUARY, 15, 0, 0));
when(agreementTypeConfigService.getDisplayTypes())
.thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE));
when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name()))
.thenReturn(Arrays.asList(latest, older));
agreementsController.showTerms("terms", "2025-01-15", model);
assertSame(older, model.getAttribute("selectedAgreement"));
assertEquals("2025-01-15", model.getAttribute("selectedDate"));
}
@Test
@DisplayName("일치하는 제정일이 없으면 첫 항목으로 되돌린다")
void showTerms_unmatchedPublishedOnFallsBackToFirst() {
AgreementsDTO latest = agreement("v2", LocalDateTime.of(2026, Month.MARCH, 1, 0, 0));
when(agreementTypeConfigService.getDisplayTypes())
.thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE));
when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name()))
.thenReturn(Collections.singletonList(latest));
agreementsController.showTerms("terms", "1999-12-31", model);
assertSame(latest, model.getAttribute("selectedAgreement"));
}
@Test
@DisplayName("아직 시드되지 않은 약관이면 selectedAgreement 는 null 이다")
void showTerms_emptyAgreementListIsDefended() {
when(agreementTypeConfigService.getDisplayTypes())
.thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE));
when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name()))
.thenReturn(Collections.emptyList());
String view = agreementsController.showTerms("terms", "2026-01-01", model);
assertEquals(AgreementsController.TERMS_AGREEMENTS, view);
assertNull(model.getAttribute("selectedAgreement"));
assertEquals("terms", model.getAttribute("currentTab"));
}
@Test
@DisplayName("목록 조회가 null 을 돌려줘도 방어한다")
void showTerms_nullAgreementListIsDefended() {
when(agreementTypeConfigService.getDisplayTypes())
.thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE));
when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name()))
.thenReturn(null);
String view = agreementsController.showTerms("terms", null, model);
assertEquals(AgreementsController.TERMS_AGREEMENTS, view);
assertNull(model.getAttribute("selectedAgreement"));
}
}
@@ -0,0 +1,159 @@
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 com.eactive.apim.portal.apps.agreements.dto.AgreementsDTO;
import java.util.Collections;
import java.util.EnumSet;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.Model;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 약관 동의 폼 모델 구성 검증.
*
* <p>핵심 규칙: 관리 콘솔에서 사용하지 않도록 설정한 약관 종류는
* {@code show*} 플래그가 {@code false} 이고 본문도 {@code null} 이어야 한다.
*/
@ExtendWith(MockitoExtension.class)
class AgreementFormModelSupportTest {
@Mock
private AgreementsFacade agreementsFacade;
@Mock
private AgreementTypeConfigService agreementTypeConfigService;
@InjectMocks
private AgreementFormModelSupport agreementFormModelSupport;
private Model model;
@BeforeEach
void setUp() {
model = new ExtendedModelMap();
}
private AgreementsDTO agreement(String name) {
AgreementsDTO dto = new AgreementsDTO();
dto.setName(name);
return dto;
}
@Test
@DisplayName("세 종류 모두 사용 중이면 플래그가 켜지고 본문이 채워진다")
void applyAgreements_allEnabled() {
AgreementsDTO terms = agreement("이용약관");
AgreementsDTO privacy = agreement("개인정보수집동의서");
AgreementsDTO notification = agreement("알림 수신 동의서");
when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(true);
when(agreementTypeConfigService.getEnabledTypes()).thenReturn(EnumSet.of(
AgreementType.TERMS_OF_USE,
AgreementType.PRIVACY_COLLECT,
AgreementType.NOTIFICATION_CONSENT));
when(agreementsFacade.getAgreement(AgreementType.TERMS_OF_USE.getCode())).thenReturn(terms);
when(agreementsFacade.getAgreement(AgreementType.PRIVACY_COLLECT.getCode())).thenReturn(privacy);
when(agreementsFacade.getAgreement(AgreementType.NOTIFICATION_CONSENT.getCode())).thenReturn(notification);
agreementFormModelSupport.applyAgreements(model);
assertEquals(Boolean.TRUE, model.getAttribute("agreeAllEnabled"));
assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
assertEquals(Boolean.TRUE, model.getAttribute("showPrivacyCollect"));
assertEquals(Boolean.TRUE, model.getAttribute("showNotificationConsent"));
assertSame(terms, model.getAttribute("termsOfUse"));
assertSame(privacy, model.getAttribute("privacyCollect"));
assertSame(notification, model.getAttribute("notificationConsent"));
}
@Test
@DisplayName("사용하지 않는 종류는 플래그가 꺼지고 본문을 조회조차 하지 않는다")
void applyAgreements_disabledTypeIsHiddenAndNotFetched() {
AgreementsDTO terms = agreement("이용약관");
when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false);
when(agreementTypeConfigService.getEnabledTypes())
.thenReturn(EnumSet.of(AgreementType.TERMS_OF_USE));
when(agreementsFacade.getAgreement(AgreementType.TERMS_OF_USE.getCode())).thenReturn(terms);
agreementFormModelSupport.applyAgreements(model);
assertEquals(Boolean.FALSE, model.getAttribute("agreeAllEnabled"));
assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
assertSame(terms, model.getAttribute("termsOfUse"));
// 꺼진 항목은 플래그 false + 본문 null 이어야 화면에서 항목 자체가 사라진다
assertEquals(Boolean.FALSE, model.getAttribute("showPrivacyCollect"));
assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent"));
assertTrue(model.containsAttribute("privacyCollect"));
assertTrue(model.containsAttribute("notificationConsent"));
assertNull(model.getAttribute("privacyCollect"));
assertNull(model.getAttribute("notificationConsent"));
// 불필요한 조회가 나가면 안 된다
verify(agreementsFacade, never()).getAgreement(AgreementType.PRIVACY_COLLECT.getCode());
verify(agreementsFacade, never()).getAgreement(AgreementType.NOTIFICATION_CONSENT.getCode());
}
@Test
@DisplayName("사용 중인 종류가 하나도 없으면 전부 숨김 처리된다")
void applyAgreements_noneEnabled() {
when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false);
when(agreementTypeConfigService.getEnabledTypes()).thenReturn(Collections.emptySet());
agreementFormModelSupport.applyAgreements(model);
assertEquals(Boolean.FALSE, model.getAttribute("showTermsOfUse"));
assertEquals(Boolean.FALSE, model.getAttribute("showPrivacyCollect"));
assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent"));
assertNull(model.getAttribute("termsOfUse"));
assertNull(model.getAttribute("privacyCollect"));
assertNull(model.getAttribute("notificationConsent"));
verify(agreementsFacade, never()).getAgreement(org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("applyAgreeAllMode 는 전체동의 허용 여부만 담고 동의 항목은 건드리지 않는다")
void applyAgreeAllMode_onlyTouchesAgreeAllFlag() {
when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(true);
agreementFormModelSupport.applyAgreeAllMode(model);
assertEquals(Boolean.TRUE, model.getAttribute("agreeAllEnabled"));
assertFalse(model.containsAttribute("showTermsOfUse"));
assertFalse(model.containsAttribute("termsOfUse"));
verify(agreementTypeConfigService, never()).getEnabledTypes();
verify(agreementsFacade, never()).getAgreement(org.mockito.ArgumentMatchers.anyString());
}
@Test
@DisplayName("전체동의가 꺼져 있으면 agreeAllEnabled=false")
void applyAgreeAllMode_disabled() {
when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false);
agreementFormModelSupport.applyAgreeAllMode(model);
assertEquals(Boolean.FALSE, model.getAttribute("agreeAllEnabled"));
}
}
@@ -0,0 +1,119 @@
package com.eactive.apim.portal.apps.apis.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
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;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.ui.ExtendedModelMap;
@ExtendWith(MockitoExtension.class)
class ApiControllerTest {
@Mock private ApiService apiService;
@Mock private ApiServiceService apiServiceService;
@Mock private ApiSearchFacade apiSearchFacade;
@Mock private ApiStatusCatalogService apiStatusCatalogService;
@Mock private PortalPropertyService portalPropertyService;
private ApiController controller;
private final List<ApiSpecInfoDto> apis = new ArrayList<>();
@BeforeEach
void setUp() {
controller = new ApiController(apiService, apiServiceService, apiSearchFacade,
apiStatusCatalogService, new ApiListProperties(portalPropertyService));
for (int i = 0; i < 32; i++) {
ApiSpecInfoDto api = new ApiSpecInfoDto();
api.setApiId("api-" + i);
apis.add(api);
}
Map<String, Object> result = new HashMap<>();
result.put("apis", apis);
result.put("totalApiCount", apis.size());
result.put("selectedApiCount", apis.size());
when(apiSearchFacade.searchApis(any(ApiGroupSearch.class))).thenReturn(result);
}
@Test
void defaultSizePaginatesByFifteen() {
stubPageSize("15");
Page<?> first = listPage(0);
assertEquals(15, first.getNumberOfElements());
assertEquals(3, first.getTotalPages());
assertEquals(32, first.getTotalElements());
assertEquals(apis.subList(0, 15), first.getContent());
assertEquals(apis.subList(15, 30), listPage(1).getContent());
assertEquals(apis.subList(30, 32), listPage(2).getContent());
}
@Test
void propertyChangesApplyOnNextRequestAndOverrideRequestSize() {
stubPageSize(" 6 ");
Page<?> page = listPage(1);
assertEquals(6, page.getSize());
assertEquals(apis.subList(6, 12), page.getContent());
stubPageSize("20");
assertEquals(apis.subList(20, 32), listPage(1).getContent());
}
@Test
void invalidPropertyFallsBackToFifteen() {
for (String value : new String[] {null, "", " ", "0", "-1", "invalid", "1.5", "2147483648"}) {
stubPageSize(value);
assertEquals(15, listPage(0).getSize(), "설정값: " + value);
}
}
@Test
void largePageOffsetDoesNotOverflow() {
stubPageSize("15");
assertTrue(listPage(Integer.MAX_VALUE - 1).isEmpty());
}
@Test
void emptySearchResultsProduceEmptyPage() {
stubPageSize("15");
apis.clear();
Page<?> page = listPage(0);
assertTrue(page.isEmpty());
assertEquals(0, page.getTotalElements());
}
private void stubPageSize(String value) {
when(portalPropertyService.getOrCreateProperty(
eq("Portal"), eq("api.list.page-size"), eq("15"), anyString())).thenReturn(value);
}
private Page<?> listPage(int pageNumber) {
ExtendedModelMap model = new ExtendedModelMap();
assertEquals("apps/apis/mainApiList", controller.apiList(
new ApiGroupSearch(), PageRequest.of(pageNumber, 10), model));
Page<?> page = (Page<?>) model.get("page");
assertEquals(page.getContent(), model.get("apis"));
return page;
}
}
@@ -0,0 +1,89 @@
package com.eactive.apim.portal.apps.statistics.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
import com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.time.LocalDate;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class ApiStatisticsDateRangeTest {
private ApiStatisticsService service;
private MockMvc mvc;
@BeforeEach
void setup() {
service = mock(ApiStatisticsService.class);
PortalOrg org = mock(PortalOrg.class);
when(org.getId()).thenReturn("test-org");
PortalAuthenticatedUser user = mock(PortalAuthenticatedUser.class);
when(user.getPortalOrg()).thenReturn(org);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null));
mvc = MockMvcBuilders.standaloneSetup(new ApiStatisticsController(service)).build();
}
@AfterEach
void cleanup() {
SecurityContextHolder.clearContext();
}
@Test
void rejectsOutOfWindowAnd41DayRangesForSearchAndDownload() throws Exception {
LocalDate today = LocalDate.now();
LocalDate old = today.minusYears(1).minusDays(1);
LocalDate future = today.plusDays(1);
LocalDate[][] invalid = {{old, old}, {future, future}, {today.minusDays(40), today}};
for (LocalDate[] range : invalid) {
mvc.perform(post("/statistics/api/search").contentType(MediaType.APPLICATION_JSON)
.content(payload(range[0], range[1])))
.andExpect(status().isBadRequest());
mvc.perform(get("/statistics/api/download")
.param("startDate", range[0].toString()).param("endDate", range[1].toString()))
.andExpect(status().isBadRequest());
}
verifyNoInteractions(service);
}
@Test
void accepts40InclusiveDaysAndInvokesStatisticsService() throws Exception {
when(service.getStatistics(eq("test-org"), any())).thenReturn(ApiStatisticsResultDto.empty());
LocalDate today = LocalDate.now();
mvc.perform(post("/statistics/api/search").contentType(MediaType.APPLICATION_JSON)
.content(payload(today.minusDays(39), today)))
.andExpect(status().isOk());
verify(service).getStatistics(eq("test-org"), any());
}
@Test
void rendersCalendarBoundsFromServerDate() throws Exception {
when(service.getStatistics(eq("test-org"), any())).thenReturn(ApiStatisticsResultDto.empty());
LocalDate today = LocalDate.now();
mvc.perform(get("/statistics/api"))
.andExpect(status().isOk())
.andExpect(model().attribute("statsMinDate", today.minusYears(1).toString()))
.andExpect(model().attribute("statsMaxDate", today.toString()));
}
private String payload(LocalDate start, LocalDate end) {
return "{\"mode\":\"DAILY\",\"startDate\":\"" + start + "\",\"endDate\":\"" + end + "\"}";
}
}
@@ -0,0 +1,50 @@
package com.eactive.apim.portal.apps.statistics.dto;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class ApiStatisticsSearchDtoTest {
private boolean valid(String start, String end, String today) {
ApiStatisticsSearchDto dto = new ApiStatisticsSearchDto();
dto.setStartDate(start == null ? null : LocalDate.parse(start));
dto.setEndDate(end == null ? null : LocalDate.parse(end));
return dto.isValidDateRange(LocalDate.parse(today));
}
@Test
void acceptsTodayAndExactlyOneYearAgoButRejectsOutsideDates() {
assertTrue(valid("2026-09-09", "2026-09-09", "2026-09-09"));
assertTrue(valid("2025-09-09", "2025-09-09", "2026-09-09"));
assertFalse(valid("2025-09-08", "2025-09-08", "2026-09-09"));
assertFalse(valid("2026-09-10", "2026-09-10", "2026-09-09"));
}
@Test
void countsBothEndpointsAndHandlesLeapDays() {
assertTrue(valid("2026-08-01", "2026-09-09", "2026-09-09"));
assertFalse(valid("2026-07-31", "2026-09-09", "2026-09-09"));
assertTrue(valid("2024-02-10", "2024-03-20", "2024-04-10"));
assertFalse(valid("2024-02-09", "2024-03-20", "2024-04-10"));
assertTrue(valid("2023-02-28", "2023-02-28", "2024-02-29"));
assertFalse(valid("2023-02-27", "2023-02-27", "2024-02-29"));
}
@Test
void rejectsMissingOrReversedDates() {
assertFalse(valid(null, "2026-09-09", "2026-09-09"));
assertFalse(valid("2026-09-09", null, "2026-09-09"));
assertFalse(valid("2026-09-09", "2026-09-08", "2026-09-09"));
}
@Test
void publicValidatorUsesCurrentServerDate() {
ApiStatisticsSearchDto dto = new ApiStatisticsSearchDto();
dto.setStartDate(LocalDate.now());
dto.setEndDate(LocalDate.now());
assertTrue(dto.isValidDateRange());
}
}
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.user;
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.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
@@ -31,7 +32,10 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -54,6 +58,9 @@ class AccountControllerTest {
@Mock
private AgreementsFacade agreementsFacade;
@Mock
private AgreementFormModelSupport agreementFormModelSupport;
@Mock
private AuthFacade authFacade;
@@ -178,4 +185,59 @@ class AccountControllerTest {
.andExpect(model().attributeExists("user"));
}
}
@Test
void testOrgTransferPageAppliesAgreeAllMode() throws Exception {
PortalAuthenticatedUser authenticatedUser = new PortalAuthenticatedUser();
authenticatedUser.setId("user-1");
authenticatedUser.setLoginId("testUser");
authenticatedUser.setRoleCode(RoleCode.ROLE_USER);
PortalUserDTO dto = new PortalUserDTO();
dto.setId("user-1");
dto.setLoginId("testUser");
dto.setRoleCode(RoleCode.ROLE_USER);
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser);
when(userFacade.findById("user-1")).thenReturn(dto);
// 동의 항목 구성은 화면 그대로 두고 '전체 동의' 허용 여부만 설정을 따른다
doAnswer(invocation -> {
((org.springframework.ui.Model) invocation.getArgument(0))
.addAttribute("agreeAllEnabled", false);
return null;
}).when(agreementFormModelSupport).applyAgreeAllMode(any(org.springframework.ui.Model.class));
mockMvc.perform(get("/mypage/org-transfer"))
.andExpect(status().isOk())
.andExpect(view().name("apps/mypage/orgTransfer"))
.andExpect(model().attribute("registrationType", "corporate"))
.andExpect(model().attribute("agreeAllEnabled", false));
verify(agreementFormModelSupport).applyAgreeAllMode(any(org.springframework.ui.Model.class));
}
}
@Test
void testOrgTransferPageRejectsNonPersonalUser() throws Exception {
PortalAuthenticatedUser authenticatedUser = new PortalAuthenticatedUser();
authenticatedUser.setId("manager-1");
authenticatedUser.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
PortalUserDTO dto = new PortalUserDTO();
dto.setId("manager-1");
dto.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser);
when(userFacade.findById("manager-1")).thenReturn(dto);
mockMvc.perform(get("/mypage/org-transfer"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/mypage"));
verify(agreementFormModelSupport, never())
.applyAgreeAllMode(any(org.springframework.ui.Model.class));
}
}
}
@@ -0,0 +1,137 @@
package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.user.controller.OrgRegisterController;
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;
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
import com.eactive.apim.portal.config.PortalProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.Model;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 법인 가입 화면이 약관 노출 설정을 반영하는지 검증.
*
* <p>정상 진입과 오류 재표시 두 경로 모두에서 {@link AgreementFormModelSupport#applyAgreements}
* 를 거쳐야 한다. 직접 {@code getAgreement(...)} 를 부르면 사용 안 함으로 꺼둔 약관이 화면에 남는다.
*/
@ExtendWith(MockitoExtension.class)
class OrgRegisterControllerTest {
@Mock
private AgreementFormModelSupport agreementFormModelSupport;
@Mock
private PortalProperties portalProperties;
@Mock
private OrgRegisterFacade orgRegisterFacade;
@InjectMocks
private OrgRegisterController orgRegisterController;
private Model model;
@BeforeEach
void setUp() {
model = new ExtendedModelMap();
}
/** applyAgreements 가 실제로 모델을 채우는 것처럼 흉내낸다. */
private void stubApplyAgreements() {
doAnswer(invocation -> {
Model target = invocation.getArgument(0);
target.addAttribute("showTermsOfUse", true);
target.addAttribute("showNotificationConsent", false);
target.addAttribute("agreeAllEnabled", true);
return null;
}).when(agreementFormModelSupport).applyAgreements(any(Model.class));
}
@Test
@DisplayName("법인 가입 폼 진입 시 약관 노출 설정을 모델에 반영한다")
void getOrgAgreement_appliesAgreementSettings() {
when(portalProperties.getAuthTtl()).thenReturn(300);
stubApplyAgreements();
String view = orgRegisterController.getOrgAgreement(model);
assertEquals("apps/register/orgUserRegister", view);
assertEquals("corporate", model.getAttribute("registrationType"));
assertEquals(300, model.getAttribute("authTtl"));
verify(agreementFormModelSupport).applyAgreements(model);
assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent"));
}
@Test
@DisplayName("가입 실패로 폼을 다시 그릴 때도 약관 노출 설정을 반영한다")
void registerOrg_invalidResponseReappliesAgreementSettings() {
PortalOrgRegistrationDTO orgDTO = new PortalOrgRegistrationDTO();
orgDTO.setLoginId("corp@example.com");
stubApplyAgreements();
when(orgRegisterFacade.registerNewOrgUser(any(PortalOrgRegistrationDTO.class),
any(UserAgreementDTO.class), anyBoolean()))
.thenReturn(new ValidationResponse(false, "이미 가입된 이메일입니다."));
String view = orgRegisterController.registerOrg(
"new", orgDTO, new UserAgreementDTO(), new MockHttpSession(), model);
assertEquals("apps/register/orgUserRegister", view);
assertEquals("이미 가입된 이메일입니다.", model.getAttribute("error"));
assertEquals("corporate", model.getAttribute("registrationType"));
verify(agreementFormModelSupport).applyAgreements(model);
assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
}
@Test
@DisplayName("예외가 나도 폼을 다시 그리며 약관 노출 설정을 반영한다")
void registerOrg_exceptionReappliesAgreementSettings() {
PortalOrgRegistrationDTO orgDTO = new PortalOrgRegistrationDTO();
orgDTO.setLoginId("corp@example.com");
stubApplyAgreements();
when(orgRegisterFacade.registerNewOrgUser(any(PortalOrgRegistrationDTO.class),
any(UserAgreementDTO.class), anyBoolean()))
.thenThrow(new RuntimeException("DB down"));
String view = orgRegisterController.registerOrg(
"new", orgDTO, new UserAgreementDTO(), new MockHttpSession(), model);
assertEquals("apps/register/orgUserRegister", view);
assertEquals("처리 중 오류가 발생했습니다: DB down", model.getAttribute("error"));
verify(agreementFormModelSupport, times(1)).applyAgreements(model);
}
@Test
@DisplayName("알 수 없는 시나리오는 약관 재적용 없이 오류만 표시한다")
void registerOrg_unknownScenario() {
String view = orgRegisterController.registerOrg(
"bogus", new PortalOrgRegistrationDTO(), new UserAgreementDTO(),
new MockHttpSession(), model);
assertEquals("apps/register/orgUserRegister", view);
assertEquals("잘못된 등록입니다.", model.getAttribute("error"));
}
}
@@ -0,0 +1,99 @@
package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService;
import com.eactive.apim.portal.apps.user.controller.UserManController;
import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.Collections;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.Model;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* 사용자 목록 화면의 알림 수신 동의 노출 플래그 검증.
*
* <p>초대/초대취소 팝업의 체크박스는 알림 수신 동의서가 약관 페이지에 배치되어 있을 때만
* 노출한다(동의서 링크를 안내할 수 있어야 하므로).
*/
@ExtendWith(MockitoExtension.class)
class UserManControllerTest {
@Mock
private UserManFacade userManFacade;
@Mock
private AgreementTypeConfigService agreementTypeConfigService;
@InjectMocks
private UserManController userManController;
private Model model;
@BeforeEach
void setUp() {
model = new ExtendedModelMap();
}
private PortalAuthenticatedUser authenticatedUser() {
PortalAuthenticatedUser user = new PortalAuthenticatedUser();
user.setId("manager-1");
user.setLoginId("manager");
return user;
}
private String callUserList(boolean notificationDisplayed) {
Page<PortalUserDTO> users = new PageImpl<>(Collections.<PortalUserDTO>emptyList());
PortalOrgDTO userOrg = new PortalOrgDTO();
userOrg.setId("org-1");
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::getUserOrg).thenReturn(userOrg);
securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser());
when(userManFacade.getPendingUsers(userOrg)).thenReturn(Collections.emptyList());
when(userManFacade.getUsers(any(PortalOrgDTO.class), any(Pageable.class))).thenReturn(users);
when(agreementTypeConfigService.isDisplayed(AgreementType.NOTIFICATION_CONSENT))
.thenReturn(notificationDisplayed);
return userManController.userList(Pageable.unpaged(), model);
}
}
@Test
@DisplayName("알림 수신 동의서가 약관 페이지에 배치되어 있으면 노출 플래그가 켜진다")
void userList_notificationConsentAvailable() {
String view = callUserList(true);
assertEquals("apps/users/userList", view);
assertEquals(Boolean.TRUE, model.getAttribute("notificationConsentAvailable"));
assertEquals("manager-1", model.getAttribute("currentUserId"));
}
@Test
@DisplayName("미배치/사용안함이면 노출 플래그가 꺼진다")
void userList_notificationConsentUnavailable() {
String view = callUserList(false);
assertEquals("apps/users/userList", view);
assertEquals(Boolean.FALSE, model.getAttribute("notificationConsentAvailable"));
}
}
@@ -0,0 +1,242 @@
package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.user.controller.UserRegisterController;
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;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.Model;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import org.springframework.web.servlet.mvc.support.RedirectAttributesModelMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 개인 가입 / 법인 전환 화면이 약관 노출 설정을 반영하는지 검증.
*
* <p>약관 항목을 그리는 경로가 네 군데(최초 진입, 전환 동의 화면, 전환 동의 실패 재표시,
* 가입 실패 재표시)라 어느 하나라도 {@code applyAgreements} 를 빠뜨리면
* 사용 안 함으로 꺼둔 약관이 그 화면에서만 되살아난다.
*/
@ExtendWith(MockitoExtension.class)
class UserRegisterControllerAgreementTest {
@Mock
private PortalUserService portalUserService;
@Mock
private UserRegisterFacade userRegisterFacade;
@Mock
private AuthFacade authFacade;
@Mock
private PortalUserRepository portalUserRepository;
@Mock
private PortalOrgRepository portalOrgRepository;
@Mock
private AgreementFormModelSupport agreementFormModelSupport;
@Mock
private PortalProperties portalProperties;
@Mock
private UserInvitationRepository userInvitationRepository;
@Mock
private AgreementValidator agreementValidator;
@Mock
private PortalUserAuthService portalUserAuthService;
@InjectMocks
private UserRegisterController userRegisterController;
private Model model;
@BeforeEach
void setUp() {
model = new ExtendedModelMap();
}
/** applyAgreements 가 실제로 모델을 채우는 것처럼 흉내낸다. */
private void stubApplyAgreements() {
doAnswer(invocation -> {
Model target = invocation.getArgument(0);
target.addAttribute("showTermsOfUse", true);
target.addAttribute("showNotificationConsent", false);
target.addAttribute("agreeAllEnabled", true);
return null;
}).when(agreementFormModelSupport).applyAgreements(any(Model.class));
}
private void assertAgreementSettingsApplied() {
verify(agreementFormModelSupport).applyAgreements(model);
assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent"));
}
private PortalAuthenticatedUser authenticatedUser() {
PortalAuthenticatedUser user = new PortalAuthenticatedUser();
user.setId("user-1");
user.setLoginId("testUser");
user.setMobileNumber("01012345678");
return user;
}
private UserInvitation pendingInvitation() {
UserInvitation invitation = new UserInvitation();
invitation.setOrgId("org-1");
invitation.setToken("TOKEN123");
invitation.setStatus(InvitationStatus.PENDING);
invitation.setInvitationMobile("010-1234-5678");
return invitation;
}
private PortalOrg org() {
PortalOrg org = new PortalOrg();
org.setOrgName("제주테스트");
return org;
}
@Test
@DisplayName("개인 가입 폼 진입 시 약관 노출 설정을 반영한다")
void getUserAgreement_appliesAgreementSettings() {
when(portalProperties.getAuthTtl()).thenReturn(300);
stubApplyAgreements();
String view = userRegisterController.getUserAgreement(null, new MockHttpSession(), model);
assertEquals("apps/register/userRegister", view);
assertEquals("personal", model.getAttribute("registrationType"));
assertEquals(Boolean.FALSE, model.getAttribute("isInvited"));
assertAgreementSettingsApplied();
}
@Test
@DisplayName("가입 실패로 폼을 다시 그릴 때도 약관 노출 설정을 반영한다")
void registerUser_bindingErrorReappliesAgreementSettings() {
stubApplyAgreements();
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
BindingResult bindingResult = new BeanPropertyBindingResult(dto, "portalUser");
bindingResult.rejectValue("loginId", "invalid", "형식이 올바르지 않습니다.");
String view = userRegisterController.registerUser(
new UserAgreementDTO(), dto, bindingResult,
new MockHttpSession(), new MockHttpServletRequest(),
new RedirectAttributesModelMap(), model);
assertEquals("apps/register/userRegister", view);
assertEquals("입력값을 확인해주세요.", model.getAttribute("error"));
assertEquals("personal", model.getAttribute("registrationType"));
assertAgreementSettingsApplied();
}
@Test
@DisplayName("법인 전환 동의 화면 진입 시 약관 노출 설정을 반영한다")
void showDecisionProcessPage_appliesAgreementSettings() {
stubApplyAgreements();
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser());
when(userInvitationRepository.findFirstByInvitationMobileAndStatus(
"010-1234-5678", InvitationStatus.PENDING))
.thenReturn(Optional.of(pendingInvitation()));
when(portalOrgRepository.findById("org-1")).thenReturn(Optional.of(org()));
String view = userRegisterController.showDecisionProcessPage(new MockHttpSession(), model);
assertEquals("apps/register/userDecisionProcess", view);
assertEquals("제주테스트", model.getAttribute("orgName"));
assertEquals("corporate", model.getAttribute("registrationType"));
assertAgreementSettingsApplied();
}
}
@Test
@DisplayName("법인 전환 동의 실패로 화면을 다시 그릴 때도 약관 노출 설정을 반영한다")
void processInvitation_bindingErrorReappliesAgreementSettings() {
stubApplyAgreements();
UserAgreementDTO agreement = new UserAgreementDTO();
BindingResult bindingResult = new BeanPropertyBindingResult(agreement, "agreement");
bindingResult.reject("required", "필수 약관에 동의해야 합니다.");
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser());
when(userInvitationRepository.findFirstByInvitationMobileAndStatus(
"010-1234-5678", InvitationStatus.PENDING))
.thenReturn(Optional.of(pendingInvitation()));
when(portalOrgRepository.findById("org-1")).thenReturn(Optional.of(org()));
String view = userRegisterController.processInvitation(
agreement, "accept", bindingResult, "TOKEN123",
new RedirectAttributesModelMap(), new MockHttpSession(), model);
assertEquals("apps/register/userDecisionProcess", view);
assertEquals("corporate", model.getAttribute("registrationType"));
assertAgreementSettingsApplied();
}
}
@Test
@DisplayName("가입 응답이 실패면 약관 노출 설정을 반영해 폼을 다시 그린다")
void registerUser_invalidResponseReappliesAgreementSettings() {
stubApplyAgreements();
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
dto.setLoginId("user@example.com");
BindingResult bindingResult = new BeanPropertyBindingResult(dto, "portalUser");
when(userRegisterFacade.registerNewUser(any(UserAgreementDTO.class),
any(PortalUserRegistrationDTO.class), any(BindingResult.class),
any(), any(Model.class)))
.thenReturn(new ValidationResponse(false, "이미 가입된 이메일입니다."));
String view = userRegisterController.registerUser(
new UserAgreementDTO(), dto, bindingResult,
new MockHttpSession(), new MockHttpServletRequest(),
new RedirectAttributesModelMap(), model);
assertEquals("apps/register/userRegister", view);
assertEquals("이미 가입된 이메일입니다.", model.getAttribute("error"));
assertAgreementSettingsApplied();
}
}
@@ -0,0 +1,237 @@
package com.eactive.apim.portal.common.compatibility;
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.support.RequestDataValueProcessor;
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
import org.thymeleaf.spring5.SpringTemplateEngine;
import org.thymeleaf.spring5.view.ThymeleafView;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ThymeleafBootMvcCompatibilityTest {
// No user-defined engine/resolver: Boot must create and initialize both.
private final WebApplicationContextRunner runner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class, ThymeleafAutoConfiguration.class))
.withUserConfiguration(SupportConfiguration.class)
.withPropertyValues("spring.thymeleaf.prefix=classpath:/templates/",
"spring.thymeleaf.suffix=.html", "spring.thymeleaf.encoding=UTF-8", "spring.thymeleaf.cache=false");
@AfterEach
void clearThreadContexts() {
SecurityContextHolder.clearContext();
RequestContextHolder.resetRequestAttributes();
}
@Test
void bootAutoConfigurationInitializesAndRendersLayout() {
runner.run(context -> {
assertThat(context).hasNotFailed().hasSingleBean(SpringTemplateEngine.class)
.hasSingleBean(ThymeleafViewResolver.class);
SpringTemplateEngine engine = context.getBean(SpringTemplateEngine.class);
assertThat(engine.getConfiguration()).isNotNull();
assertThat(engine.getDialects()).anyMatch(LayoutDialect.class::isInstance)
.anyMatch(SpringSecurityDialect.class::isInstance);
Map<String, Object> model = new HashMap<>();
model.put("name", "portal");
model.put("date", java.time.LocalDateTime.of(2026, 9, 7, 12, 0));
assertThat(render(context, "compatibility/page", model))
.contains("layout-shell", "<b>PORTAL</b>", "2026.09.07").doesNotContain(">default</main>");
});
}
@Test
void mvcFormKeepsBindingSelectionErrorsMessagesAndRequestDataProcessing() {
runner.run(context -> {
Form form = new Form();
BindingResult errors = new BeanPropertyBindingResult(form, "form");
errors.rejectValue("email", "email.invalid");
Map<String, Object> model = new HashMap<>();
model.put("form", form);
model.put(BindingResult.MODEL_KEY_PREFIX + "form", errors);
String html = render(context, "compatibility/form", model);
assertThat(html).contains("가입 정보", "이메일 확인 필요", "name=\"email\"", "value=\"user@example.com\"",
"action=\"/portal/submit\"", "name=\"role\"", "value=\"USER\" selected=\"selected\"",
"name=\"_csrf\"", "value=\"compat-token\"")
.doesNotContain("value=\"ADMIN\" selected=\"selected\"", "th:field", "th:errors");
RecordingProcessor processor = context.getBean(RecordingProcessor.class);
assertThat(processor.fieldTypes).containsExactly("email", "option", "option");
assertThat(processor.actions).containsExactly("POST /portal/submit");
// A different selection and a valid form must clear the error and old selection.
form.setRole("ADMIN");
model.put(BindingResult.MODEL_KEY_PREFIX + "form", new BeanPropertyBindingResult(form, "form"));
assertThat(render(context, "compatibility/form", model))
.contains("value=\"ADMIN\" selected=\"selected\"")
.doesNotContain("이메일 확인 필요", "value=\"USER\" selected=\"selected\"");
});
}
@Test
void anonymousUserSeesOnlyAnonymousContent() {
SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken(
"compatibility", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
.contains("로그인 안내").doesNotContain("회원 메뉴", "관리자 메뉴", "id=\"identity\""));
}
@Test
void authenticatedUserSeesIdentityAndUserContent() {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
"portal-user", "unused", AuthorityUtils.createAuthorityList("ROLE_USER")));
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
.contains("회원 메뉴", ">portal-user</span>").doesNotContain("로그인 안내", "관리자 메뉴"));
}
@Test
void administratorSeesRoleProtectedContent() {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
"portal-admin", "unused", AuthorityUtils.createAuthorityList("ROLE_ADMIN")));
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
.contains("회원 메뉴", "관리자 메뉴", ">portal-admin</span>").doesNotContain("로그인 안내"));
}
@Test
void apiSelectorUsesSharedPageSizeForClientAndWebhookRegistrationAndModification() {
PortalPropertyService properties = mock(PortalPropertyService.class);
runner.withBean("apiListProperties", ApiListProperties.class, () -> new ApiListProperties(properties))
.run(context -> {
for (int pageSize : new int[]{15, 6}) {
when(properties.getOrCreateProperty(eq("Portal"), eq("api.list.page-size"),
eq("15"), anyString())).thenReturn(String.valueOf(pageSize));
for (String action : new String[]{"/clients/register/step2", "/clients/modify/step2",
"/webhook/register/step2", "/webhook/modify/step2"}) {
Map<String, Object> model = new HashMap<>();
model.put("apiServices", Collections.emptyList());
model.put("selectedApis", Collections.singletonList("selected-api"));
model.put("formAction", action);
model.put("saveAction", action + "/save");
String html = render(context, "views/fragment/api_selector :: apiSelector", model);
assertThat(html).contains("window.API_SELECTOR_PAGE_SIZE = " + pageSize + ";",
"action=\"/portal" + action + "\"",
"window.API_SELECTOR_SELECTED = [\"selected-api\"];");
}
}
});
}
private String render(WebApplicationContext context, String template, Map<String, Object> model) throws Exception {
context.getServletContext().setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, context);
MockHttpServletRequest request = new MockHttpServletRequest(context.getServletContext());
request.setContextPath("/portal");
request.setRequestURI("/portal/signup");
request.setMethod("GET");
request.setPreferredLocales(Collections.singletonList(Locale.KOREA));
MockHttpServletResponse response = new MockHttpServletResponse();
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response));
try {
ThymeleafViewResolver resolver = context.getBean(ThymeleafViewResolver.class);
assertThat(resolver.resolveViewName(template, Locale.KOREA)).isInstanceOf(ThymeleafView.class);
resolver.resolveViewName(template, Locale.KOREA).render(model, request, response);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8");
return response.getContentAsString();
} finally {
RequestContextHolder.resetRequestAttributes();
}
}
@Configuration(proxyBeanMethods = false)
static class SupportConfiguration {
@Bean
LayoutDialect layoutDialect() {
return new LayoutDialect();
}
@Bean
StaticMessageSource messageSource() {
StaticMessageSource messages = new StaticMessageSource();
messages.addMessage("form.title", Locale.KOREA, "가입 정보");
messages.addMessage("email.invalid", Locale.KOREA, "이메일 확인 필요");
return messages;
}
@Bean
RecordingProcessor requestDataValueProcessor() {
return new RecordingProcessor();
}
@Bean
DefaultWebSecurityExpressionHandler webSecurityExpressionHandler() {
return new DefaultWebSecurityExpressionHandler();
}
}
static class RecordingProcessor implements RequestDataValueProcessor {
final List<String> fieldTypes = new ArrayList<>();
final List<String> actions = new ArrayList<>();
@Override
public String processAction(HttpServletRequest request, String action, String method) {
actions.add(method.toUpperCase(Locale.ROOT) + " " + action);
return action;
}
@Override
public String processFormFieldValue(HttpServletRequest request, String name, String value, String type) {
fieldTypes.add(type);
return value;
}
@Override
public Map<String, String> getExtraHiddenFields(HttpServletRequest request) {
return Collections.singletonMap("_csrf", "compat-token");
}
@Override
public String processUrl(HttpServletRequest request, String url) {
return url;
}
}
public static class Form {
private String email = "user@example.com";
private String role = "USER";
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
}
@@ -0,0 +1,127 @@
package com.eactive.apim.portal.common.compatibility;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.Locale;
import java.util.stream.Stream;
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
import org.thymeleaf.spring5.SpringTemplateEngine;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.StringTemplateResolver;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
/** The original 15 probe scenarios, using only classpath fixtures and production fragments. */
class ThymeleafExpressionCompatibilityTest {
static Context context() {
Context context = new Context(Locale.KOREA);
context.setVariable("name", "portal");
context.setVariable("items", Arrays.asList("a", "b"));
context.setVariable("date", LocalDateTime.of(2026, 9, 7, 12, 0));
return context;
}
@TestFactory
Stream<DynamicTest> expressionCompatibility() {
return Stream.of(false, true).flatMap(spring -> {
TemplateEngine engine = spring ? new SpringTemplateEngine() : new TemplateEngine();
engine.setTemplateResolver(new StringTemplateResolver());
String name = spring ? "SpringEL " : "OGNL ";
return Stream.of(
dynamicTest(name + "initialization", () -> assertThat(engine.getConfiguration()).isNotNull()),
dynamicTest(name + "property", () -> assertThat(engine.process(
"<p th:text=\"${name}\"></p>", context())).isEqualTo("<p>portal</p>")),
dynamicTest(name + "method", () -> assertThat(engine.process(
"<p th:text=\"${name.toUpperCase()}\"></p>", context())).isEqualTo("<p>PORTAL</p>")),
dynamicTest(name + "collection and condition", () -> assertThat(engine.process(
"<p th:if=\"${#lists.size(items) > 1}\" th:text=\"${items[1]}\"></p>", context()))
.isEqualTo("<p>b</p>")),
dynamicTest(name + "date", () -> assertThat(engine.process(
"<p th:text=\"${#temporals.format(date, 'yyyy.MM.dd')}\"></p>", context()))
.isEqualTo("<p>2026.09.07</p>")),
dynamicTest(name + "JavaScript inline", () -> assertThat(engine.process(
"<script th:inline=\"javascript\">var name = [[${name}]];</script>", context()))
.isEqualTo("<script>var name = \"portal\";</script>")));
});
}
private SpringTemplateEngine resourceEngine() {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("templates/");
resolver.setSuffix(".html");
resolver.setCharacterEncoding("UTF-8");
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
return engine;
}
@Test
void layoutCompositionWithSecurityDialect() {
SpringTemplateEngine engine = resourceEngine();
engine.addDialect(new LayoutDialect());
engine.addDialect(new SpringSecurityDialect());
assertThat(engine.process("compatibility/page", context()))
.contains("layout-shell", "<b>PORTAL</b>", "<p>2026.09.07</p>")
.doesNotContain(">default</main>", "layout:decorate", "th:text");
}
@Test
void actualPaginationKeepsNumbersEventsAndBoundaryStates() {
SpringTemplateEngine engine = resourceEngine();
Context context = context();
context.setVariable("jsFunction", "loadPage");
for (int pageNumber : new int[]{0, 2, 4}) {
context.setVariable("page", new PageImpl<>(Arrays.asList("a", "b"),
PageRequest.of(pageNumber, 10), 50));
String html = engine.process("views/fragment/pagination", Collections.singleton("pagination"), context);
assertThat(html).contains("page-current\">" + (pageNumber + 1) + "</a>",
"onclick=\"loadPage(1, 10);\"", "onclick=\"loadPage(5, 10);\"",
"onclick=\"loadPage(" + Math.max(1, pageNumber) + ", 10);\"",
"onclick=\"loadPage(" + Math.min(5, pageNumber + 2) + ", 10);\"");
for (int number = 1; number <= 5; number++) {
if (Math.abs(number - (pageNumber + 1)) <= 2) {
assertThat(html).contains(">" + number + "</a>");
} else {
assertThat(html).doesNotContain(">" + number + "</a>");
}
}
assertThat(html.contains("class=\"page-first disabled\"")).isEqualTo(pageNumber == 0);
assertThat(html.contains("class=\"page-prev disabled\"")).isEqualTo(pageNumber == 0);
assertThat(html.contains("class=\"page-next disabled\"")).isEqualTo(pageNumber == 4);
assertThat(html.contains("class=\"page-last disabled\"")).isEqualTo(pageNumber == 4);
}
}
@Test
void actualSmsAndEmailNoticeVisibility() {
SpringTemplateEngine engine = resourceEngine();
Context context = context();
for (String fragment : Arrays.asList("smsNotice", "emailNotice")) {
for (boolean show : new boolean[]{true, false}) {
for (String code : new String[]{"123456", null}) {
context.setVariable("showTestAuthNotice", show);
context.setVariable("testAuthNumber", code);
String html = engine.process("views/fragment/test-env-auth-notice",
Collections.singleton(fragment), context);
if (show && code != null) {
assertThat(html).contains("123456", "테스트 환경 안내", fragment.equals("smsNotice") ? "SMS" : "이메일");
} else {
assertThat(html).doesNotContain("123456", "test-env-notice", "테스트 환경 안내");
}
}
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
# Moment.js 호환성 테스트
Node.js 20 이상에서 실행한다.
```sh
npm ci
npx playwright install chromium
npm run test:moment
```
Linux CI에서 브라우저 시스템 라이브러리도 설치해야 한다면
`npx playwright install --with-deps chromium`을 사용한다.
Spring 서버, DB, 외부 CDN 없이 저장소의 실제 jQuery, Moment.js,
daterangepicker.js, front2.js와 달력 CSS를 Chromium에 로드한다.
초기화와 콜백은 제품 코드를 그대로 실행하며, 테스트에서 복제하거나 모킹하지 않는다.
HTML fixture는 공통 스크립트에 필요한 레이아웃과 날짜 입력 필드를 제공한다.
서울과 뉴욕 시간대 각각에서 날짜 표시, 윤년, 월/연도 경계 이동, 선택 적용,
취소, 같은 날 선택, 서머타임 경계의 직접 입력을 검증한다.
CommonJS 격리 실행에서는 CVE-2022-24785의 경로 탐색 로케일이
`require`까지 도달하지 않는지 검사한다. 브라우저의 정상 사용과 별도의 보안 회귀 검사다.
실패 시 스크린샷과 trace는 `build/playwright`에 저장된다.
실제 서버 통합과 전체 페이지의 시각적 배치는 이 테스트 범위에 포함되지 않는다.
## APP 통계 페이지
`statistics-date-range.spec.js``apiStatistics.html`의 실제 DOM과 인라인 스크립트를
실행한다. Thymeleaf가 주입할 초기 데이터와 통계 검색 API 응답만 테스트 값으로 대체한다.
일별 기간 선택/조회, 취소 및 바깥 클릭, 서버 기준 1년 전~오늘 제한,
시작일·종료일을 포함한 최대 40일 제한, 범위를 벗어난 세션 조건 폐기 및 요청 차단,
월별 전환, 앱 선택 유지, ISO 날짜와 CSRF 헤더 전송, 모바일 표시를 검증한다.
서버의 Thymeleaf 렌더링과 실제 통계 집계는 별도 통합 검증 대상이다.
통계 페이지만 실행: `npm run test:moment -- --grep 'statistics:'`
## 배포 파일 출처
- 버전: 2.30.1 (이전 버전 2.24.0)
- 공식 파일: https://raw.githubusercontent.com/moment/moment/2.30.1/min/moment.min.js
- SHA-256: `845c524969edd5b3af9aa6d8718d29fe92e8dbe25b955214a8e064a05a9a5027`
- 배포 경로: `src/main/resources/static/js/moment.min.js`
- MIT 라이선스: 동일 디렉터리의 `moment.LICENSE` (공식 태그의 LICENSE 원문)
- 보안 공지: https://github.com/moment/moment/security/advisories/GHSA-8hfj-j24r-96c4
버전 업그레이드 시 공식 배포 파일과 해시를 확인하고 버전 assertion도 갱신한다.
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Portal date range compatibility</title>
<link rel="stylesheet" href="/css/daterangepicker.css">
</head>
<body>
<!-- front2.js의 공통 레이아웃 이벤트에도 실제 DOM을 제공한다. -->
<header id="header"><nav id="nav"><div id="main_nav"></div><div id="sub"></div></nav></header>
<main id="wrap" style="padding: 100px 400px">
<form>
<label>조회 기간 <input type="text" name="daterange" style="width: 240px"></label>
<input type="hidden" name="startDate" value="2024.02.28">
<input type="hidden" name="endDate" value="2024.03.02">
<button class="datepicker_icon" type="button">달력 열기</button>
</form>
</main>
<footer>
<div class="family-sites"><button id="family-sites-toggle" type="button">관련 사이트</button><ul></ul></div>
</footer>
<!-- 제품에서 사용하는 파일과 순서 그대로 실행한다. 초기화 코드를 복제하지 않는다. -->
<script src="/plugins/jquery/jquery-3.7.1.min.js"></script>
<script src="/js/moment.min.js"></script>
<script src="/js/daterangepicker.js"></script>
<script src="/js/front2.js"></script>
</body>
</html>

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