Compare commits
32 Commits
d044ebd157
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a8531e064 | |||
| 24103d47f9 | |||
| f7531412be | |||
| ddca2a8ea4 | |||
| 094336ebf1 | |||
| a310b671e2 | |||
| d9373b00f0 | |||
| 09bc3a65f5 | |||
| 1d81681e70 | |||
| d4bafa706c | |||
| 60f9f89d90 | |||
| 8ecfaf4945 | |||
| fa736a36ab | |||
| b7869ad6e6 | |||
| 982187298c | |||
| 8118dba74d | |||
| 97489d0361 | |||
| 924c00968c | |||
| 53c3b0ee7e | |||
| b2787882bf | |||
| aafa7ac351 | |||
| b7477e10a1 | |||
| 11f1dae1f3 | |||
| 59c379b22c | |||
| d275aa12c4 | |||
| c732f1e1b2 | |||
| 9e32502ee3 | |||
| 2d0ff2bf84 | |||
| 21940a3e18 | |||
| 2e517a1e08 | |||
| 10c5e51162 | |||
| 69e5126de6 |
@@ -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>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
|
|
||||||
**EAPIM Portal**은 광주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
|
**EAPIM Portal**은 제주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
|
||||||
|
|
||||||
**기술 스택:**
|
**기술 스택:**
|
||||||
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf
|
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf
|
||||||
@@ -25,50 +25,52 @@
|
|||||||
## Git 브랜치 전략
|
## Git 브랜치 전략
|
||||||
|
|
||||||
**저장소 정보:**
|
**저장소 정보:**
|
||||||
- 기본 저장소: `ssh://git@192.168.240.178:18081/eapim/eapim-portal.git`
|
- origin: `ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git` (Gitea, 사내망)
|
||||||
- 대체 저장소: `https://git.eactive.synology.me:8090/kjb-eapim/eapim-portal.git`
|
- 이 저장소가 유일한 remote다. Jenkins 파이프라인도 같은 주소를 본다.
|
||||||
|
|
||||||
**브랜치:**
|
**브랜치:**
|
||||||
- **jenkins_with_weblogic**: Jenkins 빌드와 WebLogic 테스트를 위한 브랜치 (기본 개발 브랜치)
|
- **master**: 기본 개발 브랜치이자 배포 기준 브랜치
|
||||||
- Jenkins CI/CD 파이프라인 설정 포함
|
- 일상적인 개발 작업(기능 개발, 버그 수정)을 여기서 직접 수행한다
|
||||||
- WebLogic 배포 및 테스트 환경 설정
|
- Jenkins 4개 파이프라인(`Jenkinsfile.*`)이 모두 `origin/master` 를 체크아웃한다
|
||||||
- 일상적인 개발 작업은 이 브랜치에서 수행
|
- `Jenkinsfile.security` 는 pollSCM 으로 master 변경을 감지해 자동 실행된다
|
||||||
- 기능 개발, 버그 수정, 테스트 등 모든 개발 활동의 기본 브랜치
|
|
||||||
|
|
||||||
- **master**: 완벽히 동작하는 검증된 코드 저장소 (안정 브랜치)
|
- **feats/\***, **design\***, **develop**: 과거 작업 잔여 브랜치
|
||||||
- 프로덕션 배포 가능한 안정적인 코드만 포함
|
- 현재 활성 개발에 사용하지 않는다. 참고용으로만 남아 있다
|
||||||
- jenkins_with_weblogic 브랜치에서 충분히 테스트된 코드만 병합
|
|
||||||
- 직접 커밋 금지, Pull Request를 통한 병합만 허용
|
|
||||||
|
|
||||||
**브랜치 사용 가이드:**
|
> 과거 문서에 있던 `jenkins_with_weblogic` 브랜치는 **존재하지 않는다**.
|
||||||
|
> Jenkins/WebLogic 설정은 master 의 `Jenkinsfile.deploy` 에 통합되어 있다.
|
||||||
|
|
||||||
|
**작업 흐름:**
|
||||||
```bash
|
```bash
|
||||||
# 개발 시작 시 jenkins_with_weblogic 브랜치에서 작업
|
# master 에서 바로 작업
|
||||||
git checkout jenkins_with_weblogic
|
git checkout master
|
||||||
|
git pull origin master
|
||||||
|
|
||||||
# 기능 개발 후 커밋
|
# 커밋 후 푸시 → Jenkins security 파이프라인이 자동 트리거됨
|
||||||
git add .
|
git add .
|
||||||
git commit -m "기능 설명"
|
git commit -m "기능 설명"
|
||||||
|
git push origin master
|
||||||
# jenkins_with_weblogic 브랜치에 푸시
|
|
||||||
git push origin jenkins_with_weblogic
|
|
||||||
|
|
||||||
# 충분한 테스트 완료 후 master로 병합 (Pull Request 사용)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 빌드 명령어
|
## 빌드 명령어
|
||||||
|
|
||||||
### 애플리케이션 실행
|
### 애플리케이션 실행
|
||||||
- gradlew 사용 금지 - offline gradle 단독 실행
|
- Linux 개발 환경에서는 `gradlew` 대신 설치된 `gradle`(8.7)을 직접 쓴다. Jenkins 노드도 `/apps/opts/gradle-8.7` 의 `gradle` 을 쓴다.
|
||||||
- JDK/Gradle 환경 변수는 저장소 루트의 `.envrc`(direnv)로 자동 설정됨 (JAVA_HOME → Zulu JDK 8, GRADLE_USER_HOME → 격리 디렉터리)
|
- Windows/Eclipse 환경 기동 절차는 `BOOTRUN_SETUP_GUIDE.md` 참고 (거기서는 `gradlew.bat bootRun` 사용).
|
||||||
|
- `.envrc`(direnv)는 **없다**. JAVA_HOME(JDK 8)/GRADLE_HOME 은 셸에서 직접 맞춰야 한다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# dev 프로파일로 실행 (기본값)
|
# bootRun 기본 프로파일은 build.gradle 의 bootProfile 기본값(local_rinjaemac)
|
||||||
gradle bootRun
|
gradle bootRun
|
||||||
|
|
||||||
# 특정 프로파일로 실행
|
# 다른 프로파일로 기동 (--args 가 아니라 -PbootProfile)
|
||||||
gradle bootRun --args='--spring.profiles.active=stage'
|
gradle bootRun -PbootProfile=dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `bootRun` 의 프로파일은 `-PbootProfile` 로 넘긴다. `-Pprofile` 은 `ext.profile='local'`
|
||||||
|
> 이 이미 점유하고 있어 무시된다(`build.gradle` 의 주석 참고).
|
||||||
|
> Jenkins 빌드에서 쓰는 `-Pprofile=weblogic` 은 스프링 프로파일이 아니라 빌드용 플래그다.
|
||||||
|
|
||||||
### 빌드
|
### 빌드
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -82,6 +84,9 @@ gradle war
|
|||||||
|
|
||||||
# 클린 후 빌드
|
# 클린 후 빌드
|
||||||
gradle clean build
|
gradle clean build
|
||||||
|
|
||||||
|
# SBOM(xlsx) 생성 - Jenkins 빌드 파이프라인에서 사용
|
||||||
|
gradle sbomXlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
### 테스트
|
### 테스트
|
||||||
@@ -90,7 +95,7 @@ gradle clean build
|
|||||||
# 모든 테스트 실행
|
# 모든 테스트 실행
|
||||||
gradle test
|
gradle test
|
||||||
|
|
||||||
# 커버리지와 함께 테스트 실행
|
# 로그 상세 출력
|
||||||
gradle test --info
|
gradle test --info
|
||||||
|
|
||||||
# 특정 테스트 클래스 실행
|
# 특정 테스트 클래스 실행
|
||||||
@@ -100,24 +105,44 @@ gradle test --tests "com.eactive.apim.portal.apps.user.AccountControllerTest"
|
|||||||
gradle test --tests "*Controller*"
|
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
|
```bash
|
||||||
# 현재 프로파일 확인
|
# 현재 프로파일 확인 (-Pprofile 로 넘긴 값)
|
||||||
gradle printProfile
|
gradle printProfile
|
||||||
|
|
||||||
# 소스 세트 설정 확인
|
# 소스 세트 설정 확인
|
||||||
gradle printSourceSets
|
gradle printSourceSets
|
||||||
|
```
|
||||||
|
|
||||||
# Docker 이미지 빌드
|
### Docker
|
||||||
./build_docker.sh
|
|
||||||
|
`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`)
|
1. **elink-online-core-jpa** (`../eapim-online/elink-online-core-jpa`)
|
||||||
- Gateway 데이터 모델 및 JPA 엔티티
|
- Gateway 데이터 모델 및 JPA 엔티티
|
||||||
@@ -129,37 +154,53 @@ gradle printSourceSets
|
|||||||
- 기본 리포지토리 구현, QueryDSL 지원
|
- 기본 리포지토리 구현, QueryDSL 지원
|
||||||
- 공통 예외 핸들러 및 보안 유틸리티
|
- 공통 예외 핸들러 및 보안 유틸리티
|
||||||
|
|
||||||
3. **kjb-safedb** (`../kjb-safedb`)
|
두 모듈은 Jenkins 파이프라인의 `Checkout dependencies` 스테이지가 같은 Gitea 서버에서
|
||||||
- 광주은행 SafeDB 암호화 라이브러리
|
자동으로 clone/reset 한다. 로컬에서도 `../eapim-online/elink-online-core-jpa` 와
|
||||||
- 데이터베이스 레벨 민감 데이터 암호화
|
`../elink-portal-common` 이 없으면 컴파일 자체가 되지 않는다.
|
||||||
- 암호화 컬럼(사용자 비밀번호, API 키 등)에 필수
|
|
||||||
|
**kjb-safedb (SafeDB 암호화 라이브러리)는 현재 빌드에서 빠져 있다.**
|
||||||
|
`build.gradle` 의 `implementation project(':kjb-safedb')` 가 주석 처리되어 있고
|
||||||
|
`settings.gradle` 에도 등록되어 있지 않으며 `../kjb-safedb` 디렉터리도 없다.
|
||||||
|
다시 붙일 때는 세 곳을 모두 되살려야 한다.
|
||||||
|
|
||||||
### 패키지 구조
|
### 패키지 구조
|
||||||
|
|
||||||
코드는 기술 계층이 아닌 **기능 모듈**(수직 분할) 방식으로 구성됩니다:
|
코드는 기술 계층이 아닌 **기능 모듈**(수직 분할) 방식으로 구성됩니다:
|
||||||
|
|
||||||
```
|
```
|
||||||
com.eactive.apim.portal/
|
com.eactive.apim/
|
||||||
├── apps/ # 기능 모듈
|
├── portal/
|
||||||
│ ├── agreements/ # API 약관
|
│ ├── PortalApplication.java
|
||||||
│ ├── apis/ # API 카탈로그 & 문서
|
│ ├── apps/ # 기능 모듈
|
||||||
│ ├── apiservice/ # API 서비스 그룹핑
|
│ │ ├── HealthCheckController.java
|
||||||
│ ├── app/ # API 키 관리
|
│ │ ├── ReadinessController.java
|
||||||
│ ├── approval/ # 승인 워크플로우
|
│ │ ├── agreements/ # API 약관
|
||||||
│ ├── auth/ # 인증
|
│ │ ├── apis/ # API 카탈로그 & 문서
|
||||||
│ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의
|
│ │ ├── apiservice/ # API 서비스 그룹핑
|
||||||
│ ├── dashboard/ # 통계 & 분석
|
│ │ ├── app/ # API 키 관리
|
||||||
│ ├── file/ # 파일 업로드/다운로드
|
│ │ ├── approval/ # 승인 워크플로우
|
||||||
│ ├── login/ # 로그인/로그아웃
|
│ │ ├── auth/ # 인증
|
||||||
│ ├── proxy/ # API 테스트용 Forward Proxy
|
│ │ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의
|
||||||
│ ├── sample/ # 샘플 코드 생성
|
│ │ ├── dashboard/ # 대시보드
|
||||||
│ └── user/ # 사용자 관리
|
│ │ ├── file/ # 파일 업로드/다운로드
|
||||||
├── common/ # 공통 관심사
|
│ │ ├── login/ # 로그인/로그아웃
|
||||||
├── config/ # Spring 설정
|
│ │ ├── main/ # 메인 화면
|
||||||
└── gateway/ # Gateway DB 직접 접근
|
│ │ ├── sample/ # 샘플 코드 생성
|
||||||
└── data/ # Gateway 리포지토리
|
│ │ ├── 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)
|
- `controller/` - Spring MVC 컨트롤러 (@Controller)
|
||||||
- `service/` - 비즈니스 로직 (@Service, @Transactional)
|
- `service/` - 비즈니스 로직 (@Service, @Transactional)
|
||||||
@@ -183,6 +224,9 @@ com.eactive.apim.portal/
|
|||||||
- Entities: `com.eactive.eai.data.entity.onl.*`
|
- Entities: `com.eactive.eai.data.entity.onl.*`
|
||||||
- 목적: API 명세, 서비스, 메시지
|
- 목적: API 명세, 서비스, 메시지
|
||||||
|
|
||||||
|
두 데이터소스 모두 **dev 프로파일에서도 JNDI 로 연결한다**(`application-dev.yml`, `application-stage.yml`).
|
||||||
|
직접 JDBC URL 을 쓰는 프로파일은 없다.
|
||||||
|
|
||||||
설정: `config/PortalDatasourceConfiguration.java`
|
설정: `config/PortalDatasourceConfiguration.java`
|
||||||
- 각 데이터베이스별 별도 EntityManager
|
- 각 데이터베이스별 별도 EntityManager
|
||||||
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
|
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
|
||||||
@@ -193,18 +237,25 @@ com.eactive.apim.portal/
|
|||||||
|
|
||||||
환경별 설정 파일: `src/main/resources/application-{profile}.yml`
|
환경별 설정 파일: `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)
|
- **stage**: 스테이징 환경 (JNDI 데이터소스, proxy to inter-dapiwas01)
|
||||||
- **prod**: 운영 환경 (JNDI 데이터소스, proxy to inter-apiwas00, 캐싱 활성화)
|
- **prod**: 운영 환경 (JNDI 데이터소스, proxy to inter-apiwas00, 캐싱 활성화)
|
||||||
- **gf63**: 개인 개발 환경
|
- **local_gf63**: 개인 로컬 개발 환경
|
||||||
- **kjb_rinjae**: 개인 개발 환경
|
|
||||||
|
|
||||||
주요 설정:
|
> `bootRun` 의 기본값은 `build.gradle` 에서 `local_rinjaemac` 으로 잡혀 있지만
|
||||||
- 세션 타임아웃: 15분
|
> `application-local_rinjaemac.yml` 은 저장소에 없다. 기본값 그대로 `gradle bootRun` 을
|
||||||
- 파일 업로드 최대: 8MB
|
> 하면 해당 프로파일 설정 없이 뜨므로, 로컬 기동 시에는 `-PbootProfile=dev` 처럼 명시하는 편이 안전하다.
|
||||||
- 비밀번호 만료: 90일
|
|
||||||
- 인증 토큰 TTL: 5분 (300초)
|
주요 설정 (`application.yml`):
|
||||||
- 사용자 승인 필수: true
|
- 세션 타임아웃: **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)
|
엔티티: `Approval`, `Approver`, `ApprovalStatus` (WAITING, APPROVED, REJECTED)
|
||||||
|
|
||||||
### API 테스트 (`apps/proxy/`, `apps/sample/`)
|
### API 테스트 (`apps/apis/`)
|
||||||
|
|
||||||
- Gateway 서버로의 Forward Proxy
|
- API 테스터: `apps/apis/filter/ApiTesterFilter.java`
|
||||||
- 요청/응답 로깅이 있는 API 테스터
|
- Testbed 스펙 제공: `apps/apis/controller/TestbedSpecController.java`
|
||||||
- 여러 언어의 샘플 코드 생성
|
- 샘플 코드 템플릿: `application.yml` 의 `sample-code-path: classpath:/templates/sample_code`
|
||||||
- API 문서를 위한 Swagger UI 통합
|
|
||||||
|
> `apps/sample/` 은 샘플 코드 생성기가 아니라 **Thymeleaf/Security 데모 컨트롤러**
|
||||||
|
> (`ThymeleafDemoController`, `SecurityThymeleafDemoController` 등) 모음이다.
|
||||||
|
|
||||||
### 감사 추적 (Hibernate Envers)
|
### 감사 추적 (Hibernate Envers)
|
||||||
|
|
||||||
@@ -321,16 +374,22 @@ public interface UserMapper {
|
|||||||
보안 설정: `config/PortalConfigSecurity.java`
|
보안 설정: `config/PortalConfigSecurity.java`
|
||||||
- 커스텀 인증 관리자: `PortalAuthenticationManager`
|
- 커스텀 인증 관리자: `PortalAuthenticationManager`
|
||||||
- `changeSessionId()`를 통한 세션 고정 공격 방어
|
- `changeSessionId()`를 통한 세션 고정 공격 방어
|
||||||
- 쿠키 기반 토큰을 사용한 CSRF 방어
|
- CSRF: **`HttpSessionCsrfTokenRepository`(세션 저장)**, 헤더명 `X-XSRF-TOKEN` 고정
|
||||||
- Lucy 필터를 통한 XSS 방어
|
— 쿠키 기반(`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_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_CORP_USER (법인사용자) → [ROLE_API_KEY_REQUEST, ROLE_API_KEY_REQUEST_VIEW,
|
||||||
ROLE_ACCOUNT, ROLE_CORP_API, ROLE_DASHBOARD,
|
ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT]
|
||||||
ROLE_USER_MANAGER]
|
|
||||||
|
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`의 로깅 설정:
|
로깅은 `application.yml` 의 `logging.file.path` 가 아니라 **logback 설정으로 제어**한다.
|
||||||
```yaml
|
|
||||||
logging:
|
- 설정 파일: `src/main/resources/logback-spring.xml` (그 외 `logback-debug.xml`, `logback-local_gf63.xml`)
|
||||||
file:
|
- 로그 디렉터리: `portal.logging.log-path` yml 프로퍼티 + 인스턴스명
|
||||||
path: /Log/App/eapim/
|
```xml
|
||||||
level:
|
<springProperty scope="context" name="profileLogPath" source="portal.logging.log-path"/>
|
||||||
com.eactive.apim.portal: DEBUG
|
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
|
||||||
org.hibernate.SQL: DEBUG (개발 환경만)
|
```
|
||||||
```
|
- `application.yml` 기본값: `portal.logging.log-path: /logs/prod/eapim`
|
||||||
|
- 산출 파일: `${LOG_PATH}/portal.log`, `${LOG_PATH}/hibernate.log` 등 (`backup/` 에 일자 롤링)
|
||||||
|
|
||||||
Lombok의 `@Slf4j`와 함께 SLF4J 사용:
|
Lombok의 `@Slf4j`와 함께 SLF4J 사용:
|
||||||
```java
|
```java
|
||||||
@@ -434,12 +494,10 @@ class UserControllerTest extends BaseWebTest {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### SafeDB 테스트
|
### SafeDB
|
||||||
|
|
||||||
테스트에서는 기본적으로 SafeDB 암호화가 비활성화됩니다. 필요한 경우:
|
SafeDB 모듈이 현재 빌드에서 빠져 있으므로(위 "멀티 모듈 구조" 참고) 테스트에서
|
||||||
```bash
|
별도로 켜고 끌 것이 없다. `-Dsafedb=` 옵션은 더 이상 동작하지 않는다.
|
||||||
gradle test -Dsafedb=/path/to/safedb
|
|
||||||
```
|
|
||||||
|
|
||||||
## 중요 사항
|
## 중요 사항
|
||||||
|
|
||||||
@@ -451,15 +509,6 @@ gradle test -Dsafedb=/path/to/safedb
|
|||||||
- 한글 메시지 프로퍼티 (`messages_ko.properties`)
|
- 한글 메시지 프로퍼티 (`messages_ko.properties`)
|
||||||
- 한글 사용자 가이드 (`개발자포탈.md`)
|
- 한글 사용자 가이드 (`개발자포탈.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`에서 적용:
|
`PortalUserValidator`에서 적용:
|
||||||
@@ -472,29 +521,63 @@ API 테스트는 Gateway로의 Forward Proxy 사용:
|
|||||||
### 세션 관리
|
### 세션 관리
|
||||||
|
|
||||||
- 사용자당 단일 세션 강제
|
- 사용자당 단일 세션 강제
|
||||||
- 세션 타임아웃: 15분
|
- 세션 타임아웃: **10분** (쿠키명 `JSESSIONID_PORTAL`)
|
||||||
- 로그인 시 세션 고정 공격 방어
|
- 로그인 시 `changeSessionId()` 로 세션 고정 공격 방어
|
||||||
- Redis/Ehcache를 통한 세션 클러스터링 (스테이징/운영)
|
- 세션 상태는 DB 에 보관하며 `apps/session/` (filter/entity/repository) 과
|
||||||
|
`spring/DatabaseSessionVerifier.java` 가 담당한다. **Redis 는 쓰지 않는다.**
|
||||||
|
|
||||||
### 파일 업로드 제한
|
### 파일 업로드 제한
|
||||||
|
|
||||||
- 최대 파일 크기: 8MB
|
`application.yml` 의 `portal.file` 로 제어하고 `config/PortalProperties.java` 가 바인딩한다:
|
||||||
- `FileService`에서 허용 확장자 설정
|
|
||||||
- 파일 저장 위치: `/Log/App/eapim/files/`
|
```yaml
|
||||||
- 업로드 시 XSS 스캐닝
|
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 파일 호환 서버:
|
WAR 파일 호환 서버:
|
||||||
- **JEUS** (`jeus-web-dd.xml` 필요)
|
- **WebLogic** (`src/main/resources/weblogic.xml`) — 현재 실제 배포 대상
|
||||||
- **WebLogic** (`weblogic.xml` 필요)
|
- **JEUS** (`src/main/resources/jeus-web-dd.xml`)
|
||||||
- **Tomcat** (Spring Boot 내장)
|
- **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 설정 필수
|
||||||
|
|
||||||
스테이징/운영 환경에서 JNDI 리소스 설정:
|
dev 를 포함한 모든 환경에서 WAS 에 JNDI 리소스가 있어야 한다:
|
||||||
- `jdbc/dsOBP_EMS` → Portal 데이터베이스
|
- `jdbc/dsOBP_EMS` → Portal 데이터베이스
|
||||||
- `jdbc/dsOBP_AGW` → Gateway 데이터베이스
|
- `jdbc/dsOBP_AGW` → Gateway 데이터베이스
|
||||||
|
|
||||||
@@ -511,8 +594,8 @@ JAVA_OPTS="-Xmx2g -Xms1g -Dspring.profiles.active=prod"
|
|||||||
# WAR 빌드
|
# WAR 빌드
|
||||||
gradle bootWar
|
gradle bootWar
|
||||||
|
|
||||||
# Docker 이미지 빌드
|
# Docker 이미지 빌드 (build_docker.sh 는 없다)
|
||||||
./build_docker.sh
|
docker build -t eapim-portal:latest .
|
||||||
|
|
||||||
# 컨테이너 실행
|
# 컨테이너 실행
|
||||||
docker run -p 30200:30200 \
|
docker run -p 30200:30200 \
|
||||||
@@ -526,13 +609,16 @@ docker run -p 30200:30200 \
|
|||||||
다음 항목에 영향을 주는 변경 시:
|
다음 항목에 영향을 주는 변경 시:
|
||||||
- **Gateway API 명세**: `elink-online-core-jpa` 모듈 확인
|
- **Gateway API 명세**: `elink-online-core-jpa` 모듈 확인
|
||||||
- **공통 유틸리티**: `elink-portal-common` 모듈 확인
|
- **공통 유틸리티**: `elink-portal-common` 모듈 확인
|
||||||
- **암호화**: `kjb-safedb` 모듈 확인
|
- **암호화**: `kjb-safedb` 모듈 — 현재 빌드에서 제외됨 (위 "멀티 모듈 구조" 참고)
|
||||||
- **Admin 포털**: `../eapim-admin/` 관련 프로젝트
|
- **Admin 포털**: `../eapim-admin/` 관련 프로젝트
|
||||||
- **Online 포털**: `../eapim-online/` 관련 프로젝트
|
- **Online 포털**: `../eapim-online/` 관련 프로젝트
|
||||||
|
|
||||||
## 추가 자료
|
## 추가 자료
|
||||||
|
|
||||||
- 사용자 가이드 (한글): `개발자포탈.md`
|
- 사용자 가이드 (한글): `개발자포탈.md`
|
||||||
- 빌드 스크립트: `build-gf63.sh`, `deploy_portal.sh`, `deploy_portal2.sh`
|
- 로컬 기동 가이드 (Windows/Eclipse): `BOOTRUN_SETUP_GUIDE.md`
|
||||||
- Docker: `Dockerfile`, `build_docker.sh`
|
- 개발환경 준비, OHS 정적리소스 설정: `djb-docs/`
|
||||||
- SQL 스크립트: `../kjb-eapim-sql/`
|
- 메뉴 관리 개발 가이드: `readme-docs/메뉴-관리-개발-가이드.md`
|
||||||
|
- SASS/프론트 빌드: `package.json`, `sass-build.sh`, `tools/forge-entry.js`
|
||||||
|
- Docker: `Dockerfile`
|
||||||
|
- CI 설정: `Jenkinsfile.*`, `sonar-project.properties`, `ci/`
|
||||||
|
|||||||
+2
-1
@@ -74,8 +74,9 @@ pipeline {
|
|||||||
set -eu
|
set -eu
|
||||||
cd build/libs
|
cd build/libs
|
||||||
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
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'
|
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -43,8 +43,9 @@ pipeline {
|
|||||||
)
|
)
|
||||||
booleanParam(
|
booleanParam(
|
||||||
name: 'RUN_TESTS',
|
name: 'RUN_TESTS',
|
||||||
defaultValue: false,
|
defaultValue: true,
|
||||||
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
|
description: '단위 테스트 + JaCoCo 커버리지를 Sonar 로 전송한다(분석 시간 증가). ' +
|
||||||
|
'끄면 커버리지 리포트가 없어 Sonar 가 Coverage 를 0% 로 집계하고 Quality Gate 가 실패한다.'
|
||||||
)
|
)
|
||||||
booleanParam(
|
booleanParam(
|
||||||
name: 'UPDATE_NVD',
|
name: 'UPDATE_NVD',
|
||||||
@@ -166,14 +167,18 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// test 는 finalizedBy jacocoTestReport 로 커버리지 XML 까지 만든다(build.gradle).
|
||||||
|
// 이 스테이지를 건너뛰면 Sonar 가 커버리지 리포트를 못 받아 Coverage 0% 로 집계되고
|
||||||
|
// Quality Gate 의 "새 코드 커버리지" 조건에서 반드시 실패한다.
|
||||||
stage('Test') {
|
stage('Test') {
|
||||||
when { expression { return params.RUN_TESTS } }
|
when { expression { return params.RUN_TESTS } }
|
||||||
steps {
|
steps {
|
||||||
sh 'gradle test --no-daemon'
|
sh 'gradle test jacocoTestReport --no-daemon'
|
||||||
}
|
}
|
||||||
post {
|
post {
|
||||||
always {
|
always {
|
||||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
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"
|
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
|
||||||
fi
|
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 결과도 함께 올린다.
|
# SonarQube 에 Dependency-Check 플러그인이 설치돼 있으면 CVE 결과도 함께 올린다.
|
||||||
# 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만).
|
# 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만).
|
||||||
DC_ARG=""
|
DC_ARG=""
|
||||||
@@ -445,6 +460,7 @@ pipeline {
|
|||||||
-Dsonar.java.libraries="$LIBS" \
|
-Dsonar.java.libraries="$LIBS" \
|
||||||
-Dsonar.java.test.libraries="$TEST_LIBS" \
|
-Dsonar.java.test.libraries="$TEST_LIBS" \
|
||||||
$JUNIT_ARG \
|
$JUNIT_ARG \
|
||||||
|
$COVERAGE_ARG \
|
||||||
$DC_ARG
|
$DC_ARG
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-4
@@ -26,8 +26,9 @@ pipeline {
|
|||||||
parameters {
|
parameters {
|
||||||
booleanParam(
|
booleanParam(
|
||||||
name: 'RUN_TESTS',
|
name: 'RUN_TESTS',
|
||||||
defaultValue: false,
|
defaultValue: true,
|
||||||
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
|
description: '단위 테스트 + JaCoCo 커버리지를 Sonar 로 전송한다(분석 시간 증가). ' +
|
||||||
|
'끄면 커버리지 리포트가 없어 Sonar 가 Coverage 를 0% 로 집계하고 Quality Gate 가 실패한다.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,14 +122,16 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// test 는 finalizedBy jacocoTestReport 로 커버리지 XML 까지 만든다(build.gradle).
|
||||||
stage('Test') {
|
stage('Test') {
|
||||||
when { expression { return params.RUN_TESTS } }
|
when { expression { return params.RUN_TESTS } }
|
||||||
steps {
|
steps {
|
||||||
sh 'gradle test --no-daemon'
|
sh 'gradle test jacocoTestReport --no-daemon'
|
||||||
}
|
}
|
||||||
post {
|
post {
|
||||||
always {
|
always {
|
||||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
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"
|
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
|
||||||
fi
|
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 에서 찾는다
|
# Tools 에 등록된 스캐너가 없으면 PATH 에서 찾는다
|
||||||
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
|
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
|
||||||
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
|
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
|
||||||
@@ -215,7 +227,8 @@ pipeline {
|
|||||||
-Dsonar.projectVersion="${BUILD_NUMBER}" \
|
-Dsonar.projectVersion="${BUILD_NUMBER}" \
|
||||||
-Dsonar.java.libraries="$LIBS" \
|
-Dsonar.java.libraries="$LIBS" \
|
||||||
-Dsonar.java.test.libraries="$TEST_LIBS" \
|
-Dsonar.java.test.libraries="$TEST_LIBS" \
|
||||||
$JUNIT_ARG
|
$JUNIT_ARG \
|
||||||
|
$COVERAGE_ARG
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ pipeline {
|
|||||||
sha256sum "$f" > "$f.sha256"
|
sha256sum "$f" > "$f.sha256"
|
||||||
md5sum "$f" > "$f.md5"
|
md5sum "$f" > "$f.md5"
|
||||||
done
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-6
@@ -3,6 +3,7 @@ plugins {
|
|||||||
id 'war'
|
id 'war'
|
||||||
id 'eclipse'
|
id 'eclipse'
|
||||||
id 'idea'
|
id 'idea'
|
||||||
|
id 'jacoco'
|
||||||
id 'org.cyclonedx.bom' version '3.2.4'
|
id 'org.cyclonedx.bom' version '3.2.4'
|
||||||
id 'org.springframework.boot' version '2.7.18'
|
id 'org.springframework.boot' version '2.7.18'
|
||||||
id 'io.spring.dependency-management' version '1.1.3'
|
id 'io.spring.dependency-management' version '1.1.3'
|
||||||
@@ -87,6 +88,10 @@ dependencies {
|
|||||||
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
||||||
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
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-security'
|
||||||
implementation('org.springframework.boot:spring-boot-starter-cache')
|
implementation('org.springframework.boot:spring-boot-starter-cache')
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
@@ -111,6 +116,14 @@ dependencies {
|
|||||||
implementation 'org.jasypt:jasypt:1.9.3'
|
implementation 'org.jasypt:jasypt:1.9.3'
|
||||||
implementation 'xerces:xercesImpl:2.12.2'
|
implementation 'xerces:xercesImpl:2.12.2'
|
||||||
|
|
||||||
|
// weblogic.xml prefer-application-packages 가 com.fasterxml.jackson.* 를 앱 우선으로 강제한다
|
||||||
|
// (oracle_common jackson-databind 2.17.0 vs 앱 2.18.10 split-package 방지, WLog 실사용 확인).
|
||||||
|
// Thymeleaf StandardJavaScriptSerializer 가 ObjectMapper.findAndRegisterModules() 로
|
||||||
|
// jackson-module-jaxb-annotations 를 옵션 모듈로 자동탐색하는데, 이 모듈은 WebLogic 번들에만
|
||||||
|
// 있고 앱엔 없어 ServiceConfigurationError(Provider not found) 발생 → 앱에도 명시 추가해
|
||||||
|
// jackson 전체를 자체 완결시킨다(jackson-bom 2.18.10 로 버전 통일).
|
||||||
|
implementation 'com.fasterxml.jackson.module:jackson-module-jaxb-annotations'
|
||||||
|
|
||||||
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
|
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
|
||||||
implementation 'org.apache.commons:commons-lang3:3.20.0'
|
implementation 'org.apache.commons:commons-lang3:3.20.0'
|
||||||
implementation 'org.apache.commons:commons-collections4:4.4'
|
implementation 'org.apache.commons:commons-collections4:4.4'
|
||||||
@@ -182,13 +195,13 @@ ext {
|
|||||||
// 2.18.x 마지막 패치를 쓴다.
|
// 2.18.x 마지막 패치를 쓴다.
|
||||||
set('jackson-bom.version', '2.18.10')
|
set('jackson-bom.version', '2.18.10')
|
||||||
|
|
||||||
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
|
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
|
||||||
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
|
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
|
||||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
|
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
|
||||||
// org.thymeleaf.spring5 + javax.servlet 그대로다.
|
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
|
||||||
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
|
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
|
||||||
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
// 주의: 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')
|
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 혼재였음).
|
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
||||||
@@ -278,6 +291,33 @@ test {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
enabled = true
|
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 설정이 그대로 노출되어
|
// 로컬 전용 설정 파일. 배포 산출물(WAR)에 실리면 Actuator/SBA 설정이 그대로 노출되어
|
||||||
@@ -307,6 +347,18 @@ war {
|
|||||||
classpath = excludeLocalOnlyLibs(classpath)
|
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 {
|
task printSourceSets {
|
||||||
doLast {
|
doLast {
|
||||||
sourceSets.each { srcSet ->
|
sourceSets.each { srcSet ->
|
||||||
|
|||||||
@@ -58,11 +58,14 @@
|
|||||||
<notes><![CDATA[
|
<notes><![CDATA[
|
||||||
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
|
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
|
||||||
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
|
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
|
||||||
thymeleaf.version=3.1.4.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
|
thymeleaf.version=3.1.5.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
|
||||||
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.4.RELEASE.jar 다.
|
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.5.RELEASE.jar 다.
|
||||||
Dependency-Check 가 spring-boot-starter-thymeleaf-2.7.18.jar(의존만 선언한 빈 starter)에
|
Dependency-Check 가 spring-boot-starter-thymeleaf-2.7.18.jar(의존만 선언한 빈 starter)에
|
||||||
thymeleaf CPE 를 잘못 매칭한 결과다. thymeleaf 본체 jar 에 대한 탐지는 억제하지 않는다.
|
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>
|
]]></notes>
|
||||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$</packageUrl>
|
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$</packageUrl>
|
||||||
<cve>CVE-2026-40477</cve>
|
<cve>CVE-2026-40477</cve>
|
||||||
@@ -118,4 +121,126 @@
|
|||||||
<cve>CVE-2026-22732</cve>
|
<cve>CVE-2026-22732</cve>
|
||||||
</suppress>
|
</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>
|
</suppressions>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
## OHS Static Resource 설정 예시
|
||||||
|
|
||||||
|
```apacheconf
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName weblogic-djb.rinjae.kr
|
||||||
|
|
||||||
|
<IfModule ossl_module>
|
||||||
|
SSLEngine on
|
||||||
|
SSLVerifyClient None
|
||||||
|
SSLProtocol TLSv1.2 TLSv1.3
|
||||||
|
SSLHonorCipherOrder on
|
||||||
|
SSLCipherSuite TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||||
|
SSLWallet "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/keystores/default"
|
||||||
|
|
||||||
|
<FilesMatch "\.(cgi|shtml|phtml|php)$">
|
||||||
|
SSLOptions +StdEnvVars
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
<Directory "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/cgi-bin">
|
||||||
|
SSLOptions +StdEnvVars
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
BrowserMatch "MSIE [2-5]" \
|
||||||
|
nokeepalive ssl-unclean-shutdown \
|
||||||
|
downgrade-1.0 force-response-1.0
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header always set Strict-Transport-Security "max-age=63072000; preload; includeSubDomains"
|
||||||
|
</IfModule>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
Alias /css /apps/portal-static/css
|
||||||
|
Alias /js /apps/portal-static/js
|
||||||
|
Alias /img /apps/portal-static/img
|
||||||
|
Alias /images /apps/portal-static/images
|
||||||
|
Alias /webfonts /apps/portal-static/webfonts
|
||||||
|
Alias /font /apps/portal-static/font
|
||||||
|
Alias /html /apps/portal-static/html
|
||||||
|
Alias /plugins /apps/portal-static/plugins
|
||||||
|
Alias /favicon.png /apps/portal-static/favicon.png
|
||||||
|
|
||||||
|
<Directory "/apps/portal-static">
|
||||||
|
Require all granted
|
||||||
|
Options -Indexes
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<LocationMatch "^/favicon\.png$|^/(css|js|img|images|webfonts|font|html|plugins)/">
|
||||||
|
Header set Cache-Control "no-cache"
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
<IfModule weblogic_module>
|
||||||
|
<Location />
|
||||||
|
DirectoryIndex disabled
|
||||||
|
SetHandler weblogic-handler
|
||||||
|
WebLogicHost 172.30.1.100
|
||||||
|
WebLogicPort 39130
|
||||||
|
DynamicServerList OFF
|
||||||
|
ConnectTimeoutSecs 10
|
||||||
|
ConnectRetrySecs 2
|
||||||
|
WLProxySSL ON
|
||||||
|
</Location>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<Location /css>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /js>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /img>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /images>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /webfonts>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /font>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /html>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /plugins>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /favicon.png>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import http.cookiejar
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unicodedata
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = 'https://api.jejubank.co.kr'
|
||||||
|
COOKIE_NAME = 'JSESSIONID_PORTAL'
|
||||||
|
USER_AGENT = (
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||||
|
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||||
|
'Chrome/140.0.0.0 Safari/537.36'
|
||||||
|
)
|
||||||
|
COLUMNS = (
|
||||||
|
('회', 3),
|
||||||
|
('대상', 4),
|
||||||
|
('결과', 10),
|
||||||
|
('쿠키', 8),
|
||||||
|
('CSRF', 8),
|
||||||
|
('HTTP', 4),
|
||||||
|
('요청 값', 10),
|
||||||
|
('요청 서버ID', 11),
|
||||||
|
('응답 값', 10),
|
||||||
|
('응답 서버ID', 11),
|
||||||
|
)
|
||||||
|
BORDER = '+' + '+'.join('-' * (width + 2) for _, width in COLUMNS) + '+'
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, request, fp, code, message, headers, new_url):
|
||||||
|
# 두 서버 비교에서는 지정한 주소의 첫 응답만 검사한다.
|
||||||
|
# 공유 세션 쿠키를 리다이렉트 대상에 전달하지 않는다.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def integer_between(minimum, maximum):
|
||||||
|
def parse(value):
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||||
|
)
|
||||||
|
if not minimum <= number <= maximum:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||||
|
)
|
||||||
|
return number
|
||||||
|
return parse
|
||||||
|
|
||||||
|
|
||||||
|
def host_address(value):
|
||||||
|
value = value.strip()
|
||||||
|
message = 'http://호스트[:포트] 또는 https://호스트[:포트] 형식으로 입력하세요.'
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlsplit(value)
|
||||||
|
parsed.port # 포트 형식과 범위도 검사한다.
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(message)
|
||||||
|
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
|
||||||
|
or parsed.path not in ('', '/') or parsed.query or parsed.fragment
|
||||||
|
or parsed.username is not None or parsed.password is not None
|
||||||
|
or any(char.isspace() for char in value)):
|
||||||
|
raise argparse.ArgumentTypeError(message)
|
||||||
|
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='새 익명 세션으로 쿠키와 CSRF 토큰의 유지 여부를 표로 확인합니다.'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--host', type=host_address, default=DEFAULT_HOST,
|
||||||
|
metavar='주소', help=f'대상 호스트 주소 (기본값: {DEFAULT_HOST})',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--host2', type=host_address, metavar='주소',
|
||||||
|
help='두 번째 서버. A/B를 번갈아 요청 (리다이렉트 미추적)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--cookie-mode', choices=('shared', 'separate'), default='shared',
|
||||||
|
help='shared: 직전 세션 쿠키 공유, separate: 서버 주소/포트별 저장소 분리 (기본값: shared)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-n', '--count', type=integer_between(1, 100), default=10,
|
||||||
|
metavar='횟수', help='전체 요청 횟수: 1~100회 (기본값: 10회, 두 서버 모드도 합산)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-d', '--delay', '--delay-ms', type=integer_between(0, 10000), default=50,
|
||||||
|
metavar='밀리초', help='요청 사이 대기시간: 0~10000ms (기본값: 50ms)',
|
||||||
|
)
|
||||||
|
tls_options = parser.add_mutually_exclusive_group()
|
||||||
|
tls_options.add_argument(
|
||||||
|
'--cacert', metavar='CA파일',
|
||||||
|
help='신뢰할 사설 CA 인증서 또는 인증서 묶음 파일 (PEM 형식)',
|
||||||
|
)
|
||||||
|
tls_options.add_argument(
|
||||||
|
'-k', '--insecure', action='store_true',
|
||||||
|
help='HTTPS 서버 인증서와 호스트명 검증 생략',
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def tls_context(args):
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
if args.cacert:
|
||||||
|
context.load_verify_locations(cafile=args.cacert)
|
||||||
|
if args.insecure:
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_from_headers(headers):
|
||||||
|
value = None
|
||||||
|
for header in headers:
|
||||||
|
parsed = SimpleCookie()
|
||||||
|
parsed.load(header)
|
||||||
|
if COOKIE_NAME in parsed:
|
||||||
|
value = parsed[COOKIE_NAME].value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_for_url(jar, url):
|
||||||
|
request = urllib.request.Request(url)
|
||||||
|
jar.add_cookie_header(request)
|
||||||
|
return cookie_from_headers([request.get_header('Cookie', '')])
|
||||||
|
|
||||||
|
|
||||||
|
def host_key(url):
|
||||||
|
parsed = urllib.parse.urlsplit(url)
|
||||||
|
port = parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)
|
||||||
|
return parsed.scheme, parsed.hostname, port
|
||||||
|
|
||||||
|
|
||||||
|
def carry_session_cookie(request, value):
|
||||||
|
# 현재 호스트에 해당하는 다른 쿠키는 유지하고 포털 세션 쿠키만 이어 보낸다.
|
||||||
|
cookies = SimpleCookie()
|
||||||
|
cookies.load(request.get_header('Cookie', ''))
|
||||||
|
cookies[COOKIE_NAME] = value
|
||||||
|
request.add_unredirected_header(
|
||||||
|
'Cookie', cookies.output(header='', sep='; ').strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def server_id(cookie):
|
||||||
|
return cookie.split('!')[1] if cookie and '!' in cookie else '-'
|
||||||
|
|
||||||
|
|
||||||
|
def cell(value, width):
|
||||||
|
text = str(value)
|
||||||
|
# 한글은 보통 터미널에서 두 칸을 차지하므로 표시 폭을 기준으로 정렬한다.
|
||||||
|
display_width = sum(
|
||||||
|
0 if unicodedata.combining(char) else
|
||||||
|
2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1
|
||||||
|
for char in text
|
||||||
|
)
|
||||||
|
return text + ' ' * max(0, width - display_width)
|
||||||
|
|
||||||
|
|
||||||
|
def print_row(values):
|
||||||
|
print('| ' + ' | '.join(
|
||||||
|
cell(value, width) for value, (_, width) in zip(values, COLUMNS)
|
||||||
|
) + ' |', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def change_state(previous, current):
|
||||||
|
if previous is None:
|
||||||
|
return '최초'
|
||||||
|
return '유지' if previous == current else '!!변경!!'
|
||||||
|
|
||||||
|
|
||||||
|
def run_probe(args):
|
||||||
|
targets = [('A', args.host + '/api/session/csrf')]
|
||||||
|
if args.host2:
|
||||||
|
targets.append(('B', args.host2 + '/api/session/csrf'))
|
||||||
|
try:
|
||||||
|
context = tls_context(args)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
print(f'TLS 설정 실패: {error}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
cookie_processor = urllib.request.HTTPCookieProcessor(jar)
|
||||||
|
handlers = [
|
||||||
|
urllib.request.HTTPSHandler(context=context),
|
||||||
|
cookie_processor,
|
||||||
|
]
|
||||||
|
if args.host2:
|
||||||
|
handlers.append(NoRedirectHandler())
|
||||||
|
client = urllib.request.build_opener(*handlers)
|
||||||
|
previous_cookie = None
|
||||||
|
previous_token = None
|
||||||
|
cookie_stores = {}
|
||||||
|
session_states = {}
|
||||||
|
totals = {'최초': 0, '유지': 0, '변경': 0}
|
||||||
|
completed = 0
|
||||||
|
failure = None
|
||||||
|
exit_code = 0
|
||||||
|
|
||||||
|
for target, url in targets:
|
||||||
|
print(f'대상 {target}: {url}')
|
||||||
|
if len(targets) == 2:
|
||||||
|
print('호출 순서: A -> B -> A -> B ... (클라이언트 1개, 전체 요청 횟수 기준)')
|
||||||
|
print('두 서버 비교에서는 3xx 리다이렉트를 따라가지 않습니다.')
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
print('쿠키 모드: separate | 서버 주소/포트별 저장소 및 변경 비교 기준 분리')
|
||||||
|
elif len(targets) == 2:
|
||||||
|
print(f'쿠키 모드: shared | 직전 {COOKIE_NAME}을 다음 서버로 전달')
|
||||||
|
if any(url.startswith('https://') for _, url in targets):
|
||||||
|
tls_mode = ('생략 (--insecure)' if args.insecure else
|
||||||
|
f'사설 CA 추가 ({args.cacert})' if args.cacert else '기본 CA 사용')
|
||||||
|
print(f'TLS 인증서 검증: {tls_mode}')
|
||||||
|
print(f'쿠키 키: {COOKIE_NAME} | 값: 앞 10자리 | 횟수: {args.count}회 | 딜레이: {args.delay}ms')
|
||||||
|
print(BORDER)
|
||||||
|
print_row([title for title, _ in COLUMNS])
|
||||||
|
print(BORDER, flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for number in range(1, args.count + 1):
|
||||||
|
if number > 1 and args.delay:
|
||||||
|
time.sleep(args.delay / 1000)
|
||||||
|
|
||||||
|
target, url = targets[(number - 1) % len(targets)]
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
key = host_key(url)
|
||||||
|
if key not in cookie_stores:
|
||||||
|
cookie_stores[key] = http.cookiejar.CookieJar()
|
||||||
|
jar = cookie_stores[key]
|
||||||
|
# 순차 요청마다 같은 클라이언트의 쿠키 저장소만 교체한다.
|
||||||
|
cookie_processor.cookiejar = jar
|
||||||
|
previous_cookie, previous_token = session_states.get(key, (None, None))
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# 응답이 저장소를 갱신하기 전에 실제 전송할 쿠키를 기록한다.
|
||||||
|
jar.add_cookie_header(request)
|
||||||
|
if args.host2 and args.cookie_mode == 'shared' and previous_cookie is not None:
|
||||||
|
carry_session_cookie(request, previous_cookie)
|
||||||
|
request_cookie = cookie_from_headers([request.get_header('Cookie', '')])
|
||||||
|
response_cookie = None
|
||||||
|
status = '-'
|
||||||
|
try:
|
||||||
|
with client.open(request, timeout=10) as response:
|
||||||
|
status = response.status
|
||||||
|
response_url = response.geturl()
|
||||||
|
# 이번 응답의 Set-Cookie만 표시한다. 재발급이 없으면 '없음'.
|
||||||
|
response_cookie = cookie_from_headers(
|
||||||
|
response.headers.get_all('Set-Cookie') or []
|
||||||
|
)
|
||||||
|
data = json.load(response)
|
||||||
|
|
||||||
|
if args.host2 and args.cookie_mode == 'shared' and response_cookie is None:
|
||||||
|
# 다른 호스트에 직접 이어 보낸 쿠키는 저장소에 없을 수 있다.
|
||||||
|
# 재발급이 없으면 이번 요청에 실었던 세션을 계속 사용한다.
|
||||||
|
cookie = request_cookie
|
||||||
|
else:
|
||||||
|
cookie = cookie_for_url(jar, response_url)
|
||||||
|
token = data.get('token') if isinstance(data, dict) else None
|
||||||
|
if not cookie or not token:
|
||||||
|
raise ValueError('세션 쿠키 또는 CSRF 토큰 없음')
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
status = error.code
|
||||||
|
response_cookie = cookie_from_headers(
|
||||||
|
error.headers.get_all('Set-Cookie') or []
|
||||||
|
)
|
||||||
|
failure = f'{number}회: HTTP {status}'
|
||||||
|
error.close()
|
||||||
|
except Exception as error:
|
||||||
|
failure = f'{number}회: {error}'
|
||||||
|
|
||||||
|
if failure:
|
||||||
|
result, cookie_state, token_state = '!!실패!!', '-', '-'
|
||||||
|
exit_code = 1
|
||||||
|
else:
|
||||||
|
# 변경 판정은 앞 10자리가 아니라 전체 쿠키와 전체 토큰으로 비교한다.
|
||||||
|
cookie_state = change_state(previous_cookie, cookie)
|
||||||
|
token_state = change_state(previous_token, token)
|
||||||
|
if '!!변경!!' in (cookie_state, token_state):
|
||||||
|
outcome, result = '변경', '>>>변경<<<'
|
||||||
|
elif previous_cookie is None:
|
||||||
|
outcome, result = '최초', '[최초]'
|
||||||
|
else:
|
||||||
|
outcome, result = '유지', '[유지]'
|
||||||
|
totals[outcome] += 1
|
||||||
|
completed += 1
|
||||||
|
previous_cookie, previous_token = cookie, token
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
session_states[host_key(url)] = (cookie, token)
|
||||||
|
|
||||||
|
print_row((
|
||||||
|
number, target, result, cookie_state, token_state, status,
|
||||||
|
request_cookie[:10] if request_cookie else '없음',
|
||||||
|
server_id(request_cookie),
|
||||||
|
response_cookie[:10] if response_cookie else '없음',
|
||||||
|
server_id(response_cookie),
|
||||||
|
))
|
||||||
|
if failure:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
failure = '사용자가 중단했습니다.'
|
||||||
|
exit_code = 130
|
||||||
|
|
||||||
|
print(BORDER)
|
||||||
|
print(f'정상 조회: {completed}/{args.count}회 | 최초: {totals["최초"]}회 | 유지: {totals["유지"]}회 | 변경: {totals["변경"]}회')
|
||||||
|
print('응답 값=없음: Set-Cookie 재발급 없음. 서버ID는 각 쿠키의 ! 뒤 식별값입니다.')
|
||||||
|
if failure:
|
||||||
|
print(f'실패/중단: {failure}')
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = parse_args(argv)
|
||||||
|
started_counter = time.perf_counter()
|
||||||
|
started_at = datetime.now().astimezone()
|
||||||
|
print(f'시작 시각: {started_at.isoformat(sep=" ", timespec="milliseconds")}', flush=True)
|
||||||
|
try:
|
||||||
|
return run_probe(args)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('실패/중단: 사용자가 중단했습니다.')
|
||||||
|
return 130
|
||||||
|
finally:
|
||||||
|
finished_at = datetime.now().astimezone()
|
||||||
|
# 시스템 시각 보정에 영향받지 않도록 경과 시간은 별도 시계로 측정한다.
|
||||||
|
elapsed = time.perf_counter() - started_counter
|
||||||
|
print(f'종료 시각: {finished_at.isoformat(sep=" ", timespec="milliseconds")}')
|
||||||
|
print(f'총 소요 시간: {elapsed:.3f}초 (요청 간 딜레이 포함)', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
|
||||||
|
// Read-only dependency resolution; does not override versions or the normal build.
|
||||||
|
gradle.projectsEvaluated {
|
||||||
|
def portal = gradle.rootProject
|
||||||
|
portal.tasks.register('thymeleafCompatibilityDependencies') {
|
||||||
|
doLast {
|
||||||
|
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
|
||||||
|
output.mkdirs()
|
||||||
|
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
|
||||||
|
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
|
||||||
|
def rows = artifacts.findAll {
|
||||||
|
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||||
|
}.collect {
|
||||||
|
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
|
||||||
|
}.sort()
|
||||||
|
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
println "Dependency evidence: ${output}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_CHANGES = {
|
||||||
|
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||||
|
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||||
|
"ognl:ognl": ("3.3.4", "3.3.5"),
|
||||||
|
}
|
||||||
|
EXPECTED_CLASSES = {
|
||||||
|
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
|
||||||
|
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
|
||||||
|
"ognl/Ognl.class": "ognl-3.3.5.jar",
|
||||||
|
}
|
||||||
|
LOCAL_PREFIXES = (
|
||||||
|
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
|
||||||
|
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def dependencies(path):
|
||||||
|
result = {}
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
module, version, filename = line.split("\t")
|
||||||
|
entries = result.setdefault(module, [])
|
||||||
|
require((version, filename) not in entries, "Duplicate artifact: " + filename)
|
||||||
|
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
|
||||||
|
entries.append((version, filename))
|
||||||
|
for entries in result.values():
|
||||||
|
entries.sort()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compare(before, after):
|
||||||
|
old, new = dependencies(before), dependencies(after)
|
||||||
|
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
|
||||||
|
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
|
||||||
|
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
|
||||||
|
return {"external_modules": len(new), "changes": changed}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_war(path):
|
||||||
|
owners = {name: [] for name in EXPECTED_CLASSES}
|
||||||
|
with zipfile.ZipFile(path) as war:
|
||||||
|
names = war.namelist()
|
||||||
|
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
|
||||||
|
jars = sorted(name for name in names if name.endswith(".jar"))
|
||||||
|
basenames = [Path(name).name for name in jars]
|
||||||
|
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
|
||||||
|
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
|
||||||
|
for name in names), "Local profile packaged: " + str(path))
|
||||||
|
for name in jars:
|
||||||
|
basename = Path(name).name
|
||||||
|
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
|
||||||
|
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
|
||||||
|
classes = set(jar.namelist())
|
||||||
|
require(not any(c.startswith("jakarta/servlet/") for c in classes),
|
||||||
|
"Jakarta Servlet classes packaged: " + name)
|
||||||
|
for target in owners:
|
||||||
|
if target in classes:
|
||||||
|
owners[target].append(name)
|
||||||
|
# Java 8 compatibility of each upgraded library's entry class.
|
||||||
|
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
|
||||||
|
"Java >8 class: " + name + "!" + target)
|
||||||
|
for target, expected in EXPECTED_CLASSES.items():
|
||||||
|
require(owners[target] == ["WEB-INF/lib/" + expected],
|
||||||
|
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
|
||||||
|
for pattern, expected in [
|
||||||
|
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
|
||||||
|
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
|
||||||
|
(r"ognl-", "ognl-3.3.5.jar"),
|
||||||
|
]:
|
||||||
|
require([n for n in basenames if re.match(pattern, n)] == [expected],
|
||||||
|
"Wrong/duplicate library version for " + expected)
|
||||||
|
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
"jar_count": len(jars), "class_providers": owners, "jars": jars}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
|
||||||
|
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
|
||||||
|
parser.add_argument("--war", type=Path, action="append", required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
|
||||||
|
result = {
|
||||||
|
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
|
||||||
|
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
|
||||||
|
"wars": [inspect_war(path) for path in args.war],
|
||||||
|
}
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
print("PASS: dependency changes limited to three modules; both WARs verified")
|
||||||
|
for war in result["wars"]:
|
||||||
|
print(war["file"], war["sha256"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Generated
+542
@@ -9,9 +9,454 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"sass": "^1.69.5"
|
"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": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
@@ -322,6 +767,22 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -349,6 +810,48 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
@@ -389,6 +892,16 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
@@ -403,6 +916,35 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
|||||||
+7
-2
@@ -1,15 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "eapim-portal",
|
"name": "eapim-portal",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "SASS build system for EAPIM Portal",
|
"description": "SASS build system + forge custom bundle for EAPIM Portal",
|
||||||
"scripts": {
|
"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": "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: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",
|
"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",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ sonar.java.source=8
|
|||||||
sonar.java.binaries=build/classes/java/main
|
sonar.java.binaries=build/classes/java/main
|
||||||
sonar.java.test.binaries=build/classes/java/test
|
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=\
|
sonar.exclusions=\
|
||||||
|
|||||||
@@ -5,19 +5,36 @@ import java.sql.SQLException;
|
|||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||||
|
|
||||||
|
import com.google.common.util.concurrent.Futures;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Readiness probe.
|
* Readiness probe.
|
||||||
*
|
*
|
||||||
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
|
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
|
||||||
* This endpoint validates both EMS and Gateway datasources via JDBC
|
* This endpoint validates both EMS and Gateway datasources via JDBC
|
||||||
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
|
* {@code Connection.isValid(timeout)}, plus three libraries that WebLogic's
|
||||||
* requests that depend on the database.
|
* oracle_common bundles at a different version from this app (see weblogic.xml
|
||||||
|
* {@code prefer-application-packages}): Thymeleaf's {@link SpringTemplateEngine}
|
||||||
|
* (jackson), jboss-logging, and Guava.
|
||||||
|
*
|
||||||
|
* <p>These exist because this controller bypasses view resolution entirely
|
||||||
|
* (plain {@code @RestController} JSON) — a classpath split between the app's
|
||||||
|
* copy and WebLogic's bundled copy of a preferred package can throw
|
||||||
|
* {@link LinkageError}/{@link java.util.ServiceConfigurationError} only on the
|
||||||
|
* real code paths that touch it, while this probe kept reporting 200. That
|
||||||
|
* already happened once for jackson (Thymeleaf's dialect/module discovery
|
||||||
|
* failed on every real page while /health/ready stayed green) — jboss-logging
|
||||||
|
* and Guava carry the same risk (confirmed version mismatch against
|
||||||
|
* oracle_common, not yet observed failing in production) so they get the same
|
||||||
|
* kind of forced-touch check here.
|
||||||
*
|
*
|
||||||
* <p>HTTP 200 + JSON when all checks pass.
|
* <p>HTTP 200 + JSON when all checks pass.
|
||||||
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
|
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
|
||||||
@@ -30,24 +47,34 @@ public class ReadinessController {
|
|||||||
|
|
||||||
private final DataSource portalDataSource;
|
private final DataSource portalDataSource;
|
||||||
private final DataSource gatewayDataSource;
|
private final DataSource gatewayDataSource;
|
||||||
|
private final SpringTemplateEngine templateEngine;
|
||||||
|
|
||||||
public ReadinessController(
|
public ReadinessController(
|
||||||
@Qualifier("portalDataSource") DataSource portalDataSource,
|
@Qualifier("portalDataSource") DataSource portalDataSource,
|
||||||
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
|
@Qualifier("gatewayDataSource") DataSource gatewayDataSource,
|
||||||
|
SpringTemplateEngine templateEngine) {
|
||||||
this.portalDataSource = portalDataSource;
|
this.portalDataSource = portalDataSource;
|
||||||
this.gatewayDataSource = gatewayDataSource;
|
this.gatewayDataSource = gatewayDataSource;
|
||||||
|
this.templateEngine = templateEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<String> ready() {
|
public ResponseEntity<String> ready() {
|
||||||
String ems = check(portalDataSource);
|
String ems = check(portalDataSource);
|
||||||
String gw = check(gatewayDataSource);
|
String gw = check(gatewayDataSource);
|
||||||
boolean ok = "UP".equals(ems) && "UP".equals(gw);
|
String view = checkTemplateEngine();
|
||||||
|
String logging = checkJbossLogging();
|
||||||
|
String guava = checkGuava();
|
||||||
|
boolean ok = "UP".equals(ems) && "UP".equals(gw) && "UP".equals(view)
|
||||||
|
&& "UP".equals(logging) && "UP".equals(guava);
|
||||||
|
|
||||||
String body = "{"
|
String body = "{"
|
||||||
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
||||||
+ "\"ems\":\"" + ems + "\","
|
+ "\"ems\":\"" + ems + "\","
|
||||||
+ "\"gateway\":\""+ gw + "\""
|
+ "\"gateway\":\"" + gw + "\","
|
||||||
|
+ "\"view\":\"" + view + "\","
|
||||||
|
+ "\"logging\":\"" + logging + "\","
|
||||||
|
+ "\"guava\":\"" + guava + "\""
|
||||||
+ "}";
|
+ "}";
|
||||||
|
|
||||||
return ResponseEntity.status(ok ? 200 : 503)
|
return ResponseEntity.status(ok ? 200 : 503)
|
||||||
@@ -55,6 +82,49 @@ public class ReadinessController {
|
|||||||
.body(body);
|
.body(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@code getConfiguration()} lazily runs Thymeleaf's dialect/module init on first call
|
||||||
|
* and caches it — so this is cheap once healthy, and reproduces the exact failure path
|
||||||
|
* a real page render would hit. Catches {@link Throwable}: init failures here have
|
||||||
|
* surfaced as {@link Error} (ServiceConfigurationError), not just Exception. */
|
||||||
|
private String checkTemplateEngine() {
|
||||||
|
try {
|
||||||
|
templateEngine.getConfiguration();
|
||||||
|
return "UP";
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return down(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forces jboss-logging's {@code Logger} + its bound provider (LogManager/JBoss/JUL
|
||||||
|
* discovery) to load and actually run a log-level check, not just resolve the class. */
|
||||||
|
private static String checkJbossLogging() {
|
||||||
|
try {
|
||||||
|
Logger log = Logger.getLogger(ReadinessController.class);
|
||||||
|
log.isDebugEnabled();
|
||||||
|
return "UP";
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return down(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code Futures.immediateFuture} touches Guava's {@code util.concurrent} machinery
|
||||||
|
* (AbstractFuture/InternalFutureFailureAccess) — the part backed by the separate
|
||||||
|
* {@code failureaccess} jar, which is exactly where an app/WebLogic version mismatch
|
||||||
|
* would split across classloaders. */
|
||||||
|
private static String checkGuava() {
|
||||||
|
try {
|
||||||
|
Futures.immediateFuture(Boolean.TRUE).isDone();
|
||||||
|
return "UP";
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return down(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String down(Throwable t) {
|
||||||
|
String msg = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
|
||||||
|
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
private static String check(DataSource ds) {
|
private static String check(DataSource ds) {
|
||||||
if (ds == null) return "DOWN:NO_DATASOURCE";
|
if (ds == null) return "DOWN:NO_DATASOURCE";
|
||||||
try (Connection c = ds.getConnection()) {
|
try (Connection c = ds.getConnection()) {
|
||||||
|
|||||||
+70
-23
@@ -1,10 +1,16 @@
|
|||||||
package com.eactive.apim.portal.apps.agreements.controller;
|
package com.eactive.apim.portal.apps.agreements.controller;
|
||||||
|
|
||||||
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
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.dto.AgreementsDTO;
|
||||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -21,38 +27,56 @@ public class AgreementsController {
|
|||||||
public static final String PRIVACY_POLICY_EXTERNAL_URL =
|
public static final String PRIVACY_POLICY_EXTERNAL_URL =
|
||||||
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
|
"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 AgreementsFacade agreementsFacade;
|
||||||
|
private final AgreementTypeConfigService agreementTypeConfigService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AgreementsController(AgreementsFacade agreementsFacade) {
|
public AgreementsController(AgreementsFacade agreementsFacade,
|
||||||
|
AgreementTypeConfigService agreementTypeConfigService) {
|
||||||
this.agreementsFacade = agreementsFacade;
|
this.agreementsFacade = agreementsFacade;
|
||||||
|
this.agreementTypeConfigService = agreementTypeConfigService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/terms")
|
@GetMapping("/terms")
|
||||||
public String showTerms(@RequestParam(required = false) String tab,
|
public String showTerms(@RequestParam(required = false) String tab,
|
||||||
@RequestParam(required = false) String publishedOn,
|
@RequestParam(required = false) String publishedOn,
|
||||||
Model model) {
|
Model model) {
|
||||||
String currentTab = tab != null ? tab : "terms";
|
|
||||||
|
|
||||||
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
|
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
|
||||||
if ("privacy".equals(currentTab)) {
|
if ("privacy".equals(tab)) {
|
||||||
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
|
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
AgreementType type;
|
// 노출 대상과 순서는 관리 콘솔의 '약관 종류 관리'에서 설정한다
|
||||||
switch (currentTab) {
|
List<AgreementType> displayTypes = agreementTypeConfigService.getDisplayTypes();
|
||||||
case "privacy-collect":
|
|
||||||
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
|
// 요청한 탭이 노출 대상이 아니면(미배치/사용안함) 첫 번째 탭으로 보정한다
|
||||||
type = AgreementType.PRIVACY_COLLECT;
|
AgreementType type = resolveType(tab, displayTypes);
|
||||||
break;
|
|
||||||
case "notification":
|
if (type == null) {
|
||||||
type = AgreementType.NOTIFICATION_CONSENT;
|
// 노출할 약관 종류가 하나도 없는 경우 — 빈 화면으로 방어
|
||||||
break;
|
model.addAttribute("termsTabs", Collections.<AgreementTabDTO>emptyList());
|
||||||
case "terms":
|
model.addAttribute("agreementsList", Collections.<AgreementsDTO>emptyList());
|
||||||
default:
|
model.addAttribute("selectedAgreement", null);
|
||||||
currentTab = "terms";
|
model.addAttribute("selectedDate", publishedOn);
|
||||||
type = AgreementType.TERMS_OF_USE;
|
model.addAttribute("agreementTitle", "약관");
|
||||||
break;
|
model.addAttribute("agreementType", null);
|
||||||
|
model.addAttribute("currentTab", null);
|
||||||
|
return TERMS_AGREEMENTS;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
|
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
|
||||||
@@ -68,17 +92,40 @@ public class AgreementsController {
|
|||||||
model.addAttribute("selectedAgreement", selectedAgreement);
|
model.addAttribute("selectedAgreement", selectedAgreement);
|
||||||
model.addAttribute("selectedDate", publishedOn);
|
model.addAttribute("selectedDate", publishedOn);
|
||||||
|
|
||||||
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
|
model.addAttribute("termsTabs", buildTabs(displayTypes, type));
|
||||||
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
|
|
||||||
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
|
|
||||||
model.addAttribute("agreementTitle", type.getDescription());
|
model.addAttribute("agreementTitle", type.getDescription());
|
||||||
model.addAttribute("agreementType", type.getCode());
|
model.addAttribute("agreementType", type.getCode());
|
||||||
|
model.addAttribute("currentTab", TAB_SLUGS.get(type));
|
||||||
model.addAttribute("currentTab", currentTab);
|
|
||||||
|
|
||||||
return TERMS_AGREEMENTS;
|
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) {
|
private AgreementsDTO findAgreementByDate(List<AgreementsDTO> agreements, String publishedOn) {
|
||||||
return agreements.stream()
|
return agreements.stream()
|
||||||
.filter(a -> publishedOn.equals(
|
.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;
|
||||||
|
}
|
||||||
+54
@@ -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.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.ApiSearchFacade;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
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.dto.ApiGroupSearch;
|
||||||
@@ -17,6 +18,7 @@ import java.util.Optional;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@@ -37,6 +39,7 @@ public class ApiController {
|
|||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
private final ApiSearchFacade apiSearchFacade;
|
private final ApiSearchFacade apiSearchFacade;
|
||||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
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_ID = "default-token-api-spec";
|
||||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||||
|
|
||||||
@@ -88,7 +91,8 @@ public class ApiController {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
|
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("search", search);
|
||||||
model.addAttribute("services", searchResult.get("services"));
|
model.addAttribute("services", searchResult.get("services"));
|
||||||
@@ -109,8 +113,8 @@ public class ApiController {
|
|||||||
*/
|
*/
|
||||||
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
||||||
int total = apis == null ? 0 : apis.size();
|
int total = apis == null ? 0 : apis.size();
|
||||||
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
|
int fromIndex = (int) Math.min(pageable.getOffset(), total);
|
||||||
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
|
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
|
||||||
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
||||||
|
|
||||||
return new PageImpl<>(content, pageable, total);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
package com.eactive.apim.portal.apps.app.service;
|
package com.eactive.apim.portal.apps.app.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||||
*
|
*
|
||||||
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
|
||||||
|
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
|
||||||
|
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
|
||||||
|
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
|
|||||||
private static final String PROP_GROUP = "Portal";
|
private static final String PROP_GROUP = "Portal";
|
||||||
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||||
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||||
|
private static final String PROP_ADMIN_BASE_URL_DESC =
|
||||||
|
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
|
||||||
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
|
|||||||
/**
|
/**
|
||||||
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||||
*
|
*
|
||||||
|
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
|
||||||
|
*
|
||||||
* @param clientId 차단할 클라이언트 ID
|
* @param clientId 차단할 클라이언트 ID
|
||||||
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||||
*/
|
*/
|
||||||
public void blockClient(String clientId) {
|
public void blockClient(String clientId) {
|
||||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
List<String> baseUrls = resolveBaseUrls();
|
||||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
|
||||||
|
|
||||||
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
|
RestClientException lastError = null;
|
||||||
|
for (int i = 0; i < baseUrls.size(); i++) {
|
||||||
|
String baseUrl = baseUrls.get(i);
|
||||||
|
String url = baseUrl + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
ResponseEntity<Map> response;
|
||||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
try {
|
||||||
|
response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
// 통신 계층 실패 — 다음 admin 주소로 failover
|
||||||
|
lastError = e;
|
||||||
|
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
|
||||||
|
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
|
||||||
Map<?, ?> body = response.getBody();
|
Map<?, ?> body = response.getBody();
|
||||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||||
if (!success) {
|
if (!success) {
|
||||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||||
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
throw new IllegalStateException(
|
||||||
|
"admin clientBlock 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
|
||||||
|
*
|
||||||
|
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
|
||||||
|
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
|
||||||
|
*/
|
||||||
|
private List<String> resolveBaseUrls() {
|
||||||
|
String raw = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
|
||||||
|
|
||||||
|
List<String> urls = parseBaseUrls(raw);
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
|
||||||
|
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
|
||||||
|
*/
|
||||||
|
static List<String> parseBaseUrls(String raw) {
|
||||||
|
Set<String> ordered = new LinkedHashSet<>();
|
||||||
|
if (raw != null) {
|
||||||
|
for (String token : raw.split("[,\\r\\n]")) {
|
||||||
|
String url = stripTrailingSlashes(token.trim());
|
||||||
|
if (!url.isEmpty()) {
|
||||||
|
ordered.add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+8
-3
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
|
|||||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||||
|
|
||||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
searchDto.setStartDate(today.minusDays(7));
|
||||||
searchDto.setEndDate(LocalDate.now());
|
searchDto.setEndDate(today);
|
||||||
searchDto.setClientId(null);
|
searchDto.setClientId(null);
|
||||||
|
|
||||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||||
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
|
|||||||
model.addAttribute("details", result.getDetails());
|
model.addAttribute("details", result.getDetails());
|
||||||
model.addAttribute("periods", result.getPeriods());
|
model.addAttribute("periods", result.getPeriods());
|
||||||
model.addAttribute("searchDto", searchDto);
|
model.addAttribute("searchDto", searchDto);
|
||||||
|
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
|
||||||
|
model.addAttribute("statsMinDate", today.minusYears(1).toString());
|
||||||
|
model.addAttribute("statsMaxDate", today.toString());
|
||||||
|
|
||||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||||
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
|
|||||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||||
return searchDto.isMonthly()
|
return searchDto.isMonthly()
|
||||||
? "조회할 월이 올바르지 않습니다."
|
? "조회할 월이 올바르지 않습니다."
|
||||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
|
||||||
|
+ "일(시작일·종료일 포함)로 선택해주세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private PortalOrg getPortalOrg() {
|
private PortalOrg getPortalOrg() {
|
||||||
|
|||||||
+9
-4
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
|||||||
public class ApiStatisticsSearchDto {
|
public class ApiStatisticsSearchDto {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최대 조회 가능 일수 (일별 모드).
|
* 한 번에 조회 가능한 일수 (일별 모드, 시작일·종료일 포함).
|
||||||
*/
|
*/
|
||||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||||
|
|
||||||
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
|
||||||
*/
|
*/
|
||||||
public boolean isValidDateRange() {
|
public boolean isValidDateRange() {
|
||||||
|
return isValidDateRange(LocalDate.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isValidDateRange(LocalDate today) {
|
||||||
if (startDate == null || endDate == null) {
|
if (startDate == null || endDate == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (startDate.isAfter(endDate)) {
|
if (startDate.isAfter(endDate)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
|
LocalDate earliestDate = today.minusYears(1);
|
||||||
return daysBetween <= MAX_DATE_RANGE_DAYS;
|
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;
|
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.agreements.service.AgreementsFacade;
|
||||||
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
|
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
|
||||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||||
@@ -51,6 +52,7 @@ public class AccountController {
|
|||||||
private final UserFacade userFacade;
|
private final UserFacade userFacade;
|
||||||
private final OrgRegisterFacade orgRegisterFacade;
|
private final OrgRegisterFacade orgRegisterFacade;
|
||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementsFacade agreementsFacade;
|
||||||
|
private final AgreementFormModelSupport agreementFormModelSupport;
|
||||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
private final UserSessionService userSessionService;
|
private final UserSessionService userSessionService;
|
||||||
@@ -365,6 +367,8 @@ public class AccountController {
|
|||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
|
// 동의 항목 구성은 이 화면 그대로 두고 '전체 동의' 허용 여부만 설정을 따른다
|
||||||
|
agreementFormModelSupport.applyAgreeAllMode(model);
|
||||||
return "apps/mypage/orgTransfer";
|
return "apps/mypage/orgTransfer";
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
+6
-8
@@ -1,6 +1,6 @@
|
|||||||
package com.eactive.apim.portal.apps.user.controller;
|
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.PortalOrgRegistrationDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
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.dto.ValidationResponse;
|
||||||
@@ -21,7 +21,7 @@ public class OrgRegisterController {
|
|||||||
private static final String MAIN_ORG_REGISTER = "apps/register/orgUserRegister";
|
private static final String MAIN_ORG_REGISTER = "apps/register/orgUserRegister";
|
||||||
private static final String MAIN_REGISTER_RESULT = "apps/register/userRegisterResult";
|
private static final String MAIN_REGISTER_RESULT = "apps/register/userRegisterResult";
|
||||||
|
|
||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementFormModelSupport agreementFormModelSupport;
|
||||||
private final PortalProperties portalProperties;
|
private final PortalProperties portalProperties;
|
||||||
private final OrgRegisterFacade orgRegisterFacade;
|
private final OrgRegisterFacade orgRegisterFacade;
|
||||||
|
|
||||||
@@ -30,9 +30,8 @@ public class OrgRegisterController {
|
|||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
agreementFormModelSupport.applyAgreements(model);
|
||||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
|
||||||
return MAIN_ORG_REGISTER;
|
return MAIN_ORG_REGISTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +104,7 @@ public class OrgRegisterController {
|
|||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
model.addAttribute("portalOrg", orgDTO);
|
model.addAttribute("portalOrg", orgDTO);
|
||||||
model.addAttribute("error", errorMessage);
|
model.addAttribute("error", errorMessage);
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
agreementFormModelSupport.applyAgreements(model);
|
||||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.eactive.apim.portal.apps.user.controller;
|
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.dto.PortalUserDTO;
|
||||||
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
|
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
public class UserManController {
|
public class UserManController {
|
||||||
|
|
||||||
private final UserManFacade userManFacade;
|
private final UserManFacade userManFacade;
|
||||||
|
private final AgreementTypeConfigService agreementTypeConfigService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public String userList(@PageableDefault(sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable, Model model) {
|
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("pendingUsers", pendingUsers);
|
||||||
model.addAttribute("page", users);
|
model.addAttribute("page", users);
|
||||||
model.addAttribute("currentUserId", SecurityUtil.getPortalAuthenticatedUser().getId());
|
model.addAttribute("currentUserId", SecurityUtil.getPortalAuthenticatedUser().getId());
|
||||||
|
// 초대/초대 취소 팝업의 알림 수신 동의 체크박스 노출 여부.
|
||||||
|
// 약관 페이지에 배치되어 있어야(= 사용 중이고 미배치가 아니어야) 동의서 링크를 안내할 수 있다.
|
||||||
|
model.addAttribute("notificationConsentAvailable",
|
||||||
|
agreementTypeConfigService.isDisplayed(AgreementType.NOTIFICATION_CONSENT));
|
||||||
return "apps/users/userList";
|
return "apps/users/userList";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-22
@@ -1,6 +1,6 @@
|
|||||||
package com.eactive.apim.portal.apps.user.controller;
|
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.PortalUserRegistrationDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
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.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.PortalUserAuthService;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
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.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
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.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
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.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -61,10 +54,9 @@ public class UserRegisterController {
|
|||||||
private final AuthFacade authFacade;
|
private final AuthFacade authFacade;
|
||||||
private final PortalUserRepository portalUserRepository;
|
private final PortalUserRepository portalUserRepository;
|
||||||
private final PortalOrgRepository portalOrgRepository;
|
private final PortalOrgRepository portalOrgRepository;
|
||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementFormModelSupport agreementFormModelSupport;
|
||||||
private final PortalProperties portalProperties;
|
private final PortalProperties portalProperties;
|
||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final PortalUserAuthService portalUserAuthService;
|
private final PortalUserAuthService portalUserAuthService;
|
||||||
|
|
||||||
@@ -102,9 +94,8 @@ public class UserRegisterController {
|
|||||||
|
|
||||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||||
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
|
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
agreementFormModelSupport.applyAgreements(model);
|
||||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
|
||||||
|
|
||||||
return MAIN_USER_REGISTER;
|
return MAIN_USER_REGISTER;
|
||||||
|
|
||||||
@@ -368,9 +359,8 @@ public class UserRegisterController {
|
|||||||
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
||||||
model.addAttribute("orgName", org.get().getOrgName());
|
model.addAttribute("orgName", org.get().getOrgName());
|
||||||
model.addAttribute("invitationCode", invitation.get().getToken());
|
model.addAttribute("invitationCode", invitation.get().getToken());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
agreementFormModelSupport.applyAgreements(model);
|
||||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
|
||||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
|
|
||||||
@@ -399,9 +389,8 @@ public class UserRegisterController {
|
|||||||
|
|
||||||
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
||||||
model.addAttribute("orgName", org.get().getOrgName());
|
model.addAttribute("orgName", org.get().getOrgName());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
agreementFormModelSupport.applyAgreements(model);
|
||||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
|
||||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
|
|
||||||
@@ -443,9 +432,8 @@ public class UserRegisterController {
|
|||||||
model.addAttribute("error", errorMessage);
|
model.addAttribute("error", errorMessage);
|
||||||
model.addAttribute("agreement", agreement);
|
model.addAttribute("agreement", agreement);
|
||||||
|
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
agreementFormModelSupport.applyAgreements(model);
|
||||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
|
||||||
model.addAttribute("msgType", "sms");
|
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.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
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.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.entity.FileInfo;
|
||||||
import com.eactive.apim.portal.file.service.FileService;
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
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.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import java.io.IOException;
|
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.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final ApprovalService approvalService;
|
private final ApprovalService approvalService;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
|
|
||||||
approvalService.createUserApproval(newUser);
|
approvalService.createUserApproval(newUser);
|
||||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
|
||||||
// sendActivationEmail(newUser);
|
|
||||||
|
|
||||||
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
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(
|
private ValidationResponse convertExistingUserToCorporate(
|
||||||
PortalUser existingUser,
|
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.agreements.entity.AgreementType;
|
||||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
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.PortalUserRegistrationDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
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.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.service.UserRegistrationValidationService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
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.UserInvitation;
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
|
|||||||
|
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
private final PasswordValidator passwordValidator;
|
private final PasswordValidator passwordValidator;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final AuthNumberGenerator authNumberGenerator;
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
|
||||||
// sendEmailActivation(newUser);
|
|
||||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
return false;
|
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
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
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.entity.PortalUserEnums.RoleCode;
|
||||||
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
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.entity.MessageRequest;
|
||||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
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.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final MessageHandlerService messageHandlerService;
|
private final MessageHandlerService messageHandlerService;
|
||||||
private final MessageRequestRepository messageRequestRepository;
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final LoginFinalizer loginFinalizer;
|
private final LoginFinalizer loginFinalizer;
|
||||||
private final PasswordService passwordService;
|
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.RelatedSite;
|
||||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||||
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
||||||
|
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
@@ -51,6 +52,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private Environment environment;
|
private Environment environment;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordCryptoProperties passwordCryptoProperties;
|
||||||
|
|
||||||
@ModelAttribute("breadcrumb")
|
@ModelAttribute("breadcrumb")
|
||||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||||
String currentPath = request.getRequestURI();
|
String currentPath = request.getRequestURI();
|
||||||
@@ -125,6 +129,25 @@ public class GlobalControllerAdvice {
|
|||||||
return passwordPolicyProperties.isKeyboardSequenceBlocked();
|
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).
|
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||||
*/
|
*/
|
||||||
|
|||||||
+3
-6
@@ -1,9 +1,7 @@
|
|||||||
package com.eactive.apim.portal.common.exception;
|
package com.eactive.apim.portal.common.exception;
|
||||||
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||||
@@ -195,10 +193,9 @@ public class PortalGlobalExceptionHandler {
|
|||||||
*/
|
*/
|
||||||
@ExceptionHandler(value = Exception.class)
|
@ExceptionHandler(value = Exception.class)
|
||||||
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
||||||
String requestParams = request.getParameterMap().entrySet()
|
// 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
|
||||||
.stream()
|
// 이 시점의 파라미터는 이미 복호화된 평문이다.
|
||||||
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
|
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
|
|
||||||
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
||||||
|
|
||||||
|
|||||||
+90
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -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;
|
||||||
|
}
|
||||||
+76
@@ -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;
|
||||||
|
}
|
||||||
+18
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -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.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(AES-GCM 암호문+태그)>
|
||||||
|
* </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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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);
|
||||||
|
}
|
||||||
+57
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -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;
|
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 class EncryptionUtil {
|
||||||
|
|
||||||
public static String generateNewPassword() {
|
public static String generateNewPassword() {
|
||||||
@@ -53,40 +40,4 @@ public class EncryptionUtil {
|
|||||||
return newpassword.toString();
|
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;
|
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) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
||||||
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
||||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
"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.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 com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
@@ -64,12 +66,41 @@ public class PortalConfigSecurity {
|
|||||||
return registration;
|
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
|
@Bean
|
||||||
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
||||||
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
||||||
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
|
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
|
||||||
registrationBean.setFilter(xssEscapeServletFilter);
|
registrationBean.setFilter(xssEscapeServletFilter);
|
||||||
registrationBean.setOrder(Integer.MIN_VALUE + 1);
|
registrationBean.setOrder(Integer.MIN_VALUE + 3);
|
||||||
registrationBean.addUrlPatterns("/*");
|
registrationBean.addUrlPatterns("/*");
|
||||||
return registrationBean;
|
return registrationBean;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-11
@@ -175,31 +175,44 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
* {@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) {
|
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
||||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
boolean versioningOn = isResourceVersioningEnabled();
|
||||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
CacheControl cacheControl;
|
||||||
CacheControl cacheControl = isResourceCachingEnabled()
|
if (!isResourceCachingEnabled()) {
|
||||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
// no-store: 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다(dev/local).
|
||||||
: CacheControl.noStore();
|
cacheControl = CacheControl.noStore();
|
||||||
|
} else if (versioningOn) {
|
||||||
|
cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS);
|
||||||
|
} else {
|
||||||
|
cacheControl = CacheControl.noCache();
|
||||||
|
}
|
||||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||||
.addResourceLocations(locations)
|
.addResourceLocations(locations)
|
||||||
.setCacheControl(cacheControl)
|
.setCacheControl(cacheControl)
|
||||||
.resourceChain(isResourceCachingEnabled());
|
.resourceChain(isResourceCachingEnabled());
|
||||||
if (isResourceVersioningEnabled()) {
|
if (versioningOn) {
|
||||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||||
}
|
}
|
||||||
chain.addResolver(new PathResourceResolver());
|
chain.addResolver(new PathResourceResolver());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
|
* 정적자원 해시 버전닝 활성 여부. {@code app.resource-versioning.enabled} 값을 그대로 따른다.
|
||||||
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
|
*
|
||||||
|
* <p>과거 prod 프로파일은 이 토글과 무관하게 항상 ON 이었으나, OHS 가 정적 리소스를 직접
|
||||||
|
* 서빙하는 구조로 바뀌면서 OFF 로 전환했다 — 해시가 붙은 URL(main-<hash>.css)이 OHS
|
||||||
|
* 디스크의 무해시 원본과 어긋나 404 가 날 수 있어서다(prod.yml 에서 명시적으로 false 설정).
|
||||||
|
* 대신 캐시 신선도는 {@link #addStaticResourceHandler} 의 no-cache 조건부 재검증으로 보장한다.</p>
|
||||||
*/
|
*/
|
||||||
private boolean isResourceVersioningEnabled() {
|
private boolean isResourceVersioningEnabled() {
|
||||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return resourceVersioningEnabled;
|
return resourceVersioningEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
|
|||||||
public RestTemplate restTemplate() {
|
public RestTemplate restTemplate() {
|
||||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
|
||||||
// 연결 타임아웃: 10초
|
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
|
||||||
factory.setConnectTimeout(10000);
|
factory.setConnectTimeout(5000);
|
||||||
|
|
||||||
// 읽기 타임아웃: 30초
|
// 읽기 타임아웃: 5초
|
||||||
factory.setReadTimeout(30000);
|
factory.setReadTimeout(5000);
|
||||||
|
|
||||||
return new RestTemplate(factory);
|
return new RestTemplate(factory);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("//notitest")
|
@RequestMapping("/djb/notitest")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class NotiTestController {
|
public class NotiTestController {
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ gateway:
|
|||||||
|
|
||||||
portal:
|
portal:
|
||||||
# auth-virtual-code: 654321
|
# 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:
|
dev:
|
||||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||||
hot-reload-pages: true
|
hot-reload-pages: true
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ spring:
|
|||||||
cachecontrol:
|
cachecontrol:
|
||||||
max-age: 86400
|
max-age: 86400
|
||||||
public: true
|
public: true
|
||||||
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
|
# 정적자원 콘텐츠 해시 버전닝: OHS 가 정적 리소스(css/js/img 등)를 직접 서빙하는 구조에서는
|
||||||
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
|
# 해시 URL(main-<hash>.css)이 OHS 디스크의 무해시 원본과 어긋나 404 위험이 있어 OFF 로 전환했다
|
||||||
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
|
# (아래 app.resource-versioning.enabled: false, PortalConfigWebDispatcherServlet 참고).
|
||||||
|
# 캐시 무효화는 대신 no-cache(매 요청 If-Modified-Since 조건부 재검증)로 보장한다.
|
||||||
|
# 배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리
|
||||||
jpa:
|
jpa:
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
@@ -28,7 +30,7 @@ spring:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
resource-versioning:
|
resource-versioning:
|
||||||
enabled: true
|
enabled: false
|
||||||
resource-caching:
|
resource-caching:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
@@ -37,3 +39,14 @@ server:
|
|||||||
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
||||||
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
||||||
forward-headers-strategy: framework
|
forward-headers-strategy: framework
|
||||||
|
|
||||||
|
portal:
|
||||||
|
security:
|
||||||
|
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
|
||||||
|
password-encrypt:
|
||||||
|
enabled: true
|
||||||
|
# 운영은 평문 비밀번호를 받지 않는다. 봉투가 아니면 빈 값으로 치환돼 인증 실패로 떨어진다.
|
||||||
|
# 대가: JS 를 끈 브라우저는 로그인할 수 없고, 공개키 발급(/api/security/password-key.json)이
|
||||||
|
# 죽으면 로그인 전체가 막힌다(permissive 면 평문으로 degrade 되어 로그인은 됐을 상황).
|
||||||
|
# 되돌리려면 이 값을 permissive 로 바꾸고 재기동해야 한다 — yml 이라 무중단 토글은 안 된다.
|
||||||
|
plaintext-policy: enforce
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ spring:
|
|||||||
portal:
|
portal:
|
||||||
# 검증 단계에선 사용하지 않음
|
# 검증 단계에선 사용하지 않음
|
||||||
# auth-virtual-code: 654321
|
# auth-virtual-code: 654321
|
||||||
|
security:
|
||||||
|
# HTTPS 구간이라 전송암호화를 켠다. plaintext-policy 승격(ENFORCE)은 운영 승격 전 검증 후 판단.
|
||||||
|
password-encrypt:
|
||||||
|
enabled: true
|
||||||
|
plaintext-policy: permissive
|
||||||
dev:
|
dev:
|
||||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||||
hot-reload-pages: true
|
hot-reload-pages: true
|
||||||
|
|||||||
@@ -153,6 +153,54 @@ portal:
|
|||||||
user-approval: true
|
user-approval: true
|
||||||
password-expiration-days: 90
|
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:
|
pages:
|
||||||
- path-pattern: /dashboard/daily_usage
|
- path-pattern: /dashboard/daily_usage
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1247,7 +1247,7 @@ hr {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
|
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
|
||||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
background: rgb(0, 65.7, 162);
|
||||||
}
|
}
|
||||||
.mobile-drawer .drawer-welcome.authenticated {
|
.mobile-drawer .drawer-welcome.authenticated {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -2574,7 +2574,7 @@ hr {
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
.btn-success:hover {
|
.btn-success:hover {
|
||||||
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@@ -2582,7 +2582,7 @@ hr {
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
background: rgb(255, 70.8, 70.8);
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@@ -2848,7 +2848,7 @@ hr {
|
|||||||
.action-btn-delete:hover {
|
.action-btn-delete:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
background: rgb(255, 88.9, 88.9);
|
||||||
}
|
}
|
||||||
.action-btn-delete:active {
|
.action-btn-delete:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -2966,7 +2966,7 @@ hr {
|
|||||||
background: #a4d6ea;
|
background: #a4d6ea;
|
||||||
}
|
}
|
||||||
.btn-input-action.btn-change:hover {
|
.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);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||||
}
|
}
|
||||||
@@ -3041,7 +3041,7 @@ hr {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.btn-action-primary:hover {
|
.btn-action-primary:hover {
|
||||||
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
|
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -3091,7 +3091,7 @@ hr {
|
|||||||
}
|
}
|
||||||
.status-badge.status-processing {
|
.status-badge.status-processing {
|
||||||
background: rgba(255, 217, 61, 0.1);
|
background: rgba(255, 217, 61, 0.1);
|
||||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
color: rgb(221.2, 177.8721649485, 0);
|
||||||
}
|
}
|
||||||
.status-badge.status-failed {
|
.status-badge.status-failed {
|
||||||
background: rgba(255, 107, 107, 0.1);
|
background: rgba(255, 107, 107, 0.1);
|
||||||
@@ -3131,7 +3131,7 @@ hr {
|
|||||||
}
|
}
|
||||||
.status-badge-header.status-processing {
|
.status-badge-header.status-processing {
|
||||||
background: rgba(255, 217, 61, 0.1);
|
background: rgba(255, 217, 61, 0.1);
|
||||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
color: rgb(221.2, 177.8721649485, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-sm {
|
.badge-sm {
|
||||||
@@ -4319,7 +4319,7 @@ select.form-control {
|
|||||||
.file-upload-wrapper .file-remove-btn:hover {
|
.file-upload-wrapper .file-remove-btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
background: rgb(255, 88.9, 88.9);
|
||||||
}
|
}
|
||||||
.file-upload-wrapper .file-remove-btn:active {
|
.file-upload-wrapper .file-remove-btn:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -4640,7 +4640,7 @@ select.form-control {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.form-actions--with-withdrawal .withdrawal-link:hover {
|
.form-actions--with-withdrawal .withdrawal-link:hover {
|
||||||
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
|
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
|
||||||
}
|
}
|
||||||
.form-actions--with-withdrawal .withdrawal-link img {
|
.form-actions--with-withdrawal .withdrawal-link img {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@@ -4795,7 +4795,7 @@ select.form-control {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.notice-content-box a:hover {
|
.notice-content-box a:hover {
|
||||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
color: rgb(0, 65.7, 162);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row--content .form-label-wrapper {
|
.form-row--content .form-label-wrapper {
|
||||||
@@ -5067,6 +5067,8 @@ select.form-control {
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
.modal-body p {
|
.modal-body p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -5733,7 +5735,7 @@ select.form-control {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.drawer-logout-btn:hover {
|
.drawer-logout-btn:hover {
|
||||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
background: rgb(255, 70.8, 70.8);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
||||||
}
|
}
|
||||||
@@ -6447,7 +6449,7 @@ select.form-control {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.list-table-btn--default:hover {
|
.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 {
|
.list-table-btn--primary {
|
||||||
background-color: #ecf0fa;
|
background-color: #ecf0fa;
|
||||||
@@ -6455,7 +6457,7 @@ select.form-control {
|
|||||||
color: #2a69de;
|
color: #2a69de;
|
||||||
}
|
}
|
||||||
.list-table-btn--primary:hover {
|
.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 {
|
.list-table-btn--secondary {
|
||||||
background-color: #f5f5f4;
|
background-color: #f5f5f4;
|
||||||
@@ -6463,7 +6465,7 @@ select.form-control {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.list-table-btn--secondary:hover {
|
.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 {
|
.list-table-btn--danger {
|
||||||
background-color: #fbe7e9;
|
background-color: #fbe7e9;
|
||||||
@@ -6471,7 +6473,7 @@ select.form-control {
|
|||||||
color: #bb1026;
|
color: #bb1026;
|
||||||
}
|
}
|
||||||
.list-table-btn--danger:hover {
|
.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 {
|
.table-pagination {
|
||||||
@@ -7121,7 +7123,7 @@ select.form-control {
|
|||||||
.alert.alert-error {
|
.alert.alert-error {
|
||||||
background: rgba(255, 107, 107, 0.1);
|
background: rgba(255, 107, 107, 0.1);
|
||||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||||
color: rgb(100%, 27.7647058824%, 27.7647058824%);
|
color: rgb(255, 70.8, 70.8);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.alert.alert-error svg {
|
.alert.alert-error svg {
|
||||||
@@ -7135,7 +7137,7 @@ select.form-control {
|
|||||||
.alert.alert-success {
|
.alert.alert-success {
|
||||||
background: rgba(107, 207, 127, 0.1);
|
background: rgba(107, 207, 127, 0.1);
|
||||||
border: 1px solid rgba(107, 207, 127, 0.3);
|
border: 1px solid rgba(107, 207, 127, 0.3);
|
||||||
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
|
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
|
||||||
}
|
}
|
||||||
.alert.alert-info {
|
.alert.alert-info {
|
||||||
background: rgba(0, 73, 180, 0.1);
|
background: rgba(0, 73, 180, 0.1);
|
||||||
@@ -11549,10 +11551,10 @@ body.index-page-body {
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
.login-button:hover {
|
.login-button:hover {
|
||||||
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
|
background: rgb(25.65, 70.3, 173.85);
|
||||||
}
|
}
|
||||||
.login-button:active {
|
.login-button:active {
|
||||||
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
|
background: rgb(24.3, 66.6, 164.7);
|
||||||
}
|
}
|
||||||
.login-button:disabled {
|
.login-button:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -11595,10 +11597,10 @@ body.index-page-body {
|
|||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
}
|
}
|
||||||
.login-links-container .link-btn:hover {
|
.login-links-container .link-btn:hover {
|
||||||
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
|
background: rgb(220.61, 227.85, 245.95);
|
||||||
}
|
}
|
||||||
.login-links-container .link-btn:active {
|
.login-links-container .link-btn:active {
|
||||||
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
|
background: rgb(205.22, 215.7, 241.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-alert {
|
.login-alert {
|
||||||
@@ -12107,12 +12109,12 @@ body.index-page-body {
|
|||||||
}
|
}
|
||||||
.auth-request-button:hover,
|
.auth-request-button:hover,
|
||||||
.auth-verify-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;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
.auth-request-button:active,
|
.auth-request-button:active,
|
||||||
.auth-verify-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-request-button:disabled,
|
||||||
.auth-verify-button:disabled {
|
.auth-verify-button:disabled {
|
||||||
@@ -12161,10 +12163,10 @@ body.index-page-body {
|
|||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
}
|
}
|
||||||
.account-recovery-card .form-actions .cancel-button:hover {
|
.account-recovery-card .form-actions .cancel-button:hover {
|
||||||
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
|
background: rgb(225.45, 229.39, 235.3);
|
||||||
}
|
}
|
||||||
.account-recovery-card .form-actions .cancel-button:active {
|
.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 {
|
.account-recovery-card .form-actions .submit-button {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
@@ -12174,7 +12176,7 @@ body.index-page-body {
|
|||||||
background: rgb(6, 54, 125);
|
background: rgb(6, 54, 125);
|
||||||
}
|
}
|
||||||
.account-recovery-card .form-actions .submit-button:active {
|
.account-recovery-card .form-actions .submit-button:active {
|
||||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
background: rgb(0, 65.7, 162);
|
||||||
}
|
}
|
||||||
.account-recovery-card .form-actions .submit-button:disabled {
|
.account-recovery-card .form-actions .submit-button:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -12410,7 +12412,7 @@ body.index-page-body {
|
|||||||
transition: color 0.3s ease;
|
transition: color 0.3s ease;
|
||||||
}
|
}
|
||||||
.result-info-box .info-text .info-link:hover {
|
.result-info-box .info-text .info-link:hover {
|
||||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
color: rgb(0, 65.7, 162);
|
||||||
}
|
}
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
.result-info-box .info-text {
|
.result-info-box .info-text {
|
||||||
@@ -17610,7 +17612,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.btn-copy-action:hover {
|
.btn-copy-action:hover {
|
||||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.btn-copy-action {
|
.btn-copy-action {
|
||||||
@@ -17637,7 +17639,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.btn-view-secret:hover {
|
.btn-view-secret:hover {
|
||||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||||
}
|
}
|
||||||
.btn-view-secret svg {
|
.btn-view-secret svg {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -17822,7 +17824,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.btn-copy-action:hover {
|
.btn-copy-action:hover {
|
||||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||||
}
|
}
|
||||||
.btn-view-secret {
|
.btn-view-secret {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -17838,7 +17840,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
.btn-view-secret:hover {
|
.btn-view-secret:hover {
|
||||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||||
}
|
}
|
||||||
#revealedSecretBox {
|
#revealedSecretBox {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -18111,7 +18113,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-copy:hover {
|
.detail-wrap .dt-btn-copy:hover {
|
||||||
background: rgb(74.3529411765%, 90.2296918768%, 100%);
|
background: rgb(189.6, 230.0857142857, 255);
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-copy svg {
|
.detail-wrap .dt-btn-copy svg {
|
||||||
color: #2a69de;
|
color: #2a69de;
|
||||||
@@ -18292,7 +18294,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-gray:hover {
|
.detail-wrap .dt-btn-gray:hover {
|
||||||
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
|
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-red {
|
.detail-wrap .dt-btn-red {
|
||||||
width: 156px;
|
width: 156px;
|
||||||
@@ -18310,7 +18312,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-red:hover {
|
.detail-wrap .dt-btn-red:hover {
|
||||||
background: rgb(100%, 27.4868759774%, 25.1921568627%);
|
background: rgb(255, 70.0915337423, 64.24);
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-blue {
|
.detail-wrap .dt-btn-blue {
|
||||||
width: 156px;
|
width: 156px;
|
||||||
@@ -19884,7 +19886,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-inquiry-list:hover {
|
.btn-inquiry-list:hover {
|
||||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||||
}
|
}
|
||||||
.btn-inquiry-list:active {
|
.btn-inquiry-list:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
@@ -19917,7 +19919,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-inquiry-edit:hover {
|
.btn-inquiry-edit:hover {
|
||||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
background: rgb(0, 69.35, 171);
|
||||||
}
|
}
|
||||||
.btn-inquiry-edit:active {
|
.btn-inquiry-edit:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
@@ -19950,7 +19952,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-inquiry-delete:hover {
|
.btn-inquiry-delete:hover {
|
||||||
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
|
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
|
||||||
}
|
}
|
||||||
.btn-inquiry-delete:active {
|
.btn-inquiry-delete:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
@@ -20022,7 +20024,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
.file-upload-inline .btn-remove-file-inline:hover {
|
.file-upload-inline .btn-remove-file-inline:hover {
|
||||||
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
|
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
|
||||||
}
|
}
|
||||||
.file-upload-inline .btn-remove-file-inline svg {
|
.file-upload-inline .btn-remove-file-inline svg {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@@ -20054,7 +20056,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.file-upload-inline .btn-file-attach:hover {
|
.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 {
|
.file-upload-inline .btn-file-attach svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@@ -20114,7 +20116,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.inquiry-form-container .form-actions .btn-secondary:hover {
|
.inquiry-form-container .form-actions .btn-secondary:hover {
|
||||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||||
}
|
}
|
||||||
.inquiry-form-container .form-actions .btn-primary {
|
.inquiry-form-container .form-actions .btn-primary {
|
||||||
background: #0049b4;
|
background: #0049b4;
|
||||||
@@ -20122,7 +20124,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.inquiry-form-container .form-actions .btn-primary:hover {
|
.inquiry-form-container .form-actions .btn-primary:hover {
|
||||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
background: rgb(0, 69.35, 171);
|
||||||
}
|
}
|
||||||
.inquiry-form-container .file-upload-inline .file-input-display {
|
.inquiry-form-container .file-upload-inline .file-input-display {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
@@ -20911,7 +20913,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.djb-board-write-container .form-actions .btn-submit:hover {
|
.djb-board-write-container .form-actions .btn-submit:hover {
|
||||||
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
|
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.djb-board-write-container .form-actions .btn-submit {
|
.djb-board-write-container .form-actions .btn-submit {
|
||||||
@@ -21447,7 +21449,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.org-file-remove:hover {
|
.org-file-remove:hover {
|
||||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
background: rgb(255, 70.8, 70.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-file-notice {
|
.org-file-notice {
|
||||||
@@ -22488,7 +22490,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
.status-indicator.status-active {
|
.status-indicator.status-active {
|
||||||
background-color: rgba(107, 207, 127, 0.1);
|
background-color: rgba(107, 207, 127, 0.1);
|
||||||
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||||
}
|
}
|
||||||
.status-indicator.status-active .status-dot {
|
.status-indicator.status-active .status-dot {
|
||||||
background-color: #6BCF7F;
|
background-color: #6BCF7F;
|
||||||
@@ -25273,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
color: #666;
|
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 {
|
.btn-search {
|
||||||
background: #0049B4;
|
background: #0049B4;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -27135,7 +27165,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
.api-status .as-alert-card .as-tl-row {
|
.api-status .as-alert-card .as-tl-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 76px 1fr;
|
grid-template-columns: 60px 76px 64px 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -27143,6 +27173,37 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
color: var(--as-muted);
|
color: var(--as-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.api-status .as-alert-card .as-tl-row .as-tl-state {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
height: -webkit-fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--as-pill);
|
||||||
|
background: var(--as-gray-bg);
|
||||||
|
color: var(--as-text-2);
|
||||||
|
}
|
||||||
|
.api-status .as-alert-card .as-tl-row .as-tl-state.state-INVESTIGATING {
|
||||||
|
background: var(--as-err-bg);
|
||||||
|
color: var(--as-err);
|
||||||
|
}
|
||||||
|
.api-status .as-alert-card .as-tl-row .as-tl-state.state-IDENTIFIED {
|
||||||
|
background: #fbe9d7;
|
||||||
|
color: #c95a0f;
|
||||||
|
}
|
||||||
|
.api-status .as-alert-card .as-tl-row .as-tl-state.state-MONITORING {
|
||||||
|
background: var(--as-warn-bg);
|
||||||
|
color: var(--as-warn);
|
||||||
|
}
|
||||||
|
.api-status .as-alert-card .as-tl-row .as-tl-state.state-RESOLVED {
|
||||||
|
background: var(--as-ok-bg);
|
||||||
|
color: var(--as-ok);
|
||||||
|
}
|
||||||
|
.api-status .as-alert-card .as-tl-row .as-tl-state.state-CANCELED {
|
||||||
|
background: var(--as-gray-bg);
|
||||||
|
color: var(--as-muted);
|
||||||
|
}
|
||||||
.api-status .as-alert-card .as-tl-row .as-tl-who {
|
.api-status .as-alert-card .as-tl-row .as-tl-who {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -27292,9 +27353,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
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 {
|
.api-status .as-maint-card .as-maint-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -27306,7 +27364,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--as-text);
|
color: var(--as-info);
|
||||||
}
|
}
|
||||||
.api-status .as-maint-card .as-schedule {
|
.api-status .as-maint-card .as-schedule {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -27320,10 +27378,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
border-radius: var(--as-pill);
|
border-radius: var(--as-pill);
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.api-status .as-maint-card .as-maint-body {
|
||||||
margin: 14px 0 12px;
|
margin: 14px 0 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -27720,7 +27774,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.api-status .as-alert-card .as-tl-row {
|
.api-status .as-alert-card .as-tl-row {
|
||||||
grid-template-columns: 52px 1fr;
|
grid-template-columns: 52px 68px 1fr;
|
||||||
}
|
}
|
||||||
.api-status .as-alert-card .as-tl-who {
|
.api-status .as-alert-card .as-tl-who {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
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 |
@@ -8,7 +8,7 @@
|
|||||||
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
||||||
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||||
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
||||||
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
|
* - 페이징: #apiPagination (window.API_SELECTOR_PAGE_SIZE 건/페이지, 기본 15) — 카테고리/검색은 클라이언트에서 처리
|
||||||
*
|
*
|
||||||
* design(figma s2) 인라인 스크립트 대비 패치 4건:
|
* design(figma s2) 인라인 스크립트 대비 패치 4건:
|
||||||
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||||
@@ -22,7 +22,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return; // 모듈 미사용 페이지
|
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
|
// DOM Elements
|
||||||
const searchInput = document.getElementById('apiSearch');
|
const searchInput = document.getElementById('apiSearch');
|
||||||
|
|||||||
@@ -160,10 +160,14 @@
|
|||||||
|
|
||||||
const cards = visible.map(function (incident) {
|
const cards = visible.map(function (incident) {
|
||||||
const timeline = (incident.recentTimeline || []).map(function (entry) {
|
const timeline = (incident.recentTimeline || []).map(function (entry) {
|
||||||
|
// 지난 이슈 이력과 같은 기준: 진행 상태(labelKo)를 상태 색으로 구분해 보여준다.
|
||||||
|
const state = entry.stateAfter || 'NONE';
|
||||||
|
const label = entry.labelKo || '진행 상황';
|
||||||
const who = entry.authorType === 'SYSTEM' ? 'SYSTEM' : '운영자';
|
const who = entry.authorType === 'SYSTEM' ? 'SYSTEM' : '운영자';
|
||||||
const whoClass = entry.authorType === 'SYSTEM' ? ' is-system' : '';
|
const whoClass = entry.authorType === 'SYSTEM' ? ' is-system' : '';
|
||||||
return '<div class="as-tl-row">'
|
return '<div class="as-tl-row">'
|
||||||
+ '<span class="as-tl-time">' + escapeHtml(formatTime(entry.eventAt)) + '</span>'
|
+ '<span class="as-tl-time">' + escapeHtml(formatTime(entry.eventAt)) + '</span>'
|
||||||
|
+ '<span class="as-tl-state state-' + escapeHtml(state) + '">' + escapeHtml(label) + '</span>'
|
||||||
+ '<span class="as-tl-who' + whoClass + '">' + escapeHtml(who) + '</span>'
|
+ '<span class="as-tl-who' + whoClass + '">' + escapeHtml(who) + '</span>'
|
||||||
+ '<span>' + escapeHtml(entry.body) + '</span>'
|
+ '<span>' + escapeHtml(entry.body) + '</span>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
|
|||||||
|
Copyright (c) JS Foundation and other contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person
|
||||||
|
obtaining a copy of this software and associated documentation
|
||||||
|
files (the "Software"), to deal in the Software without
|
||||||
|
restriction, including without limitation the rights to use,
|
||||||
|
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the
|
||||||
|
Software is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||||
|
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||||
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
+2
-1
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');
|
var seg = document.getElementById('tfaSegment');
|
||||||
seg.innerHTML = '';
|
seg.innerHTML = '';
|
||||||
self._maskedByType = {};
|
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) {
|
channels.forEach(function (ch, idx) {
|
||||||
self._maskedByType[ch.type] = ch.masked;
|
self._maskedByType[ch.type] = ch.masked;
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
|
|||||||
@@ -195,6 +195,11 @@
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: $line-height-normal;
|
line-height: $line-height-normal;
|
||||||
|
// 한글은 기본값(normal)이면 음절 단위로 끊겨 "막지는 못합니 / 다" 처럼 잘린다.
|
||||||
|
// keep-all 로 어절(띄어쓰기) 단위 줄바꿈을 강제한다. 대신 공백 없는 긴 문자열(URL·키값)이
|
||||||
|
// 넘칠 수 있어 overflow-wrap 으로 그때만 강제 분리한다.
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -118,6 +118,36 @@
|
|||||||
color: #666;
|
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 {
|
.btn-search {
|
||||||
background: #0049B4;
|
background: #0049B4;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -362,7 +362,7 @@
|
|||||||
|
|
||||||
.as-tl-row {
|
.as-tl-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 76px 1fr;
|
grid-template-columns: 60px 76px 64px 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
@@ -371,6 +371,26 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 진행 상태 칩. 색 기준은 지난 이슈 이력 타임라인(.as-tl-item .as-dot)과 같다.
|
||||||
|
.as-tl-state {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
height: -webkit-fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--as-pill);
|
||||||
|
background: var(--as-gray-bg);
|
||||||
|
color: var(--as-text-2);
|
||||||
|
|
||||||
|
&.state-INVESTIGATING { background: var(--as-err-bg); color: var(--as-err); }
|
||||||
|
&.state-IDENTIFIED { background: #fbe9d7; color: #c95a0f; }
|
||||||
|
&.state-MONITORING { background: var(--as-warn-bg); color: var(--as-warn); }
|
||||||
|
&.state-RESOLVED { background: var(--as-ok-bg); color: var(--as-ok); }
|
||||||
|
&.state-CANCELED { background: var(--as-gray-bg); color: var(--as-muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작성 주체(운영자/SYSTEM)
|
||||||
.as-tl-who {
|
.as-tl-who {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -509,8 +529,6 @@
|
|||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||||
|
|
||||||
&.is-ongoing { border-left-color: var(--as-warn); }
|
|
||||||
|
|
||||||
.as-maint-head {
|
.as-maint-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -519,7 +537,7 @@
|
|||||||
flex-wrap: wrap;
|
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 {
|
.as-schedule {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -534,8 +552,6 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
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-body { margin: 14px 0 12px; font-size: 14px; line-height: 1.6; color: var(--as-text-3); }
|
||||||
|
|
||||||
.as-maint-meta {
|
.as-maint-meta {
|
||||||
@@ -908,7 +924,7 @@
|
|||||||
|
|
||||||
.as-card-head { margin-bottom: 12px; }
|
.as-card-head { margin-bottom: 12px; }
|
||||||
|
|
||||||
.as-alert-card .as-tl-row { grid-template-columns: 52px 1fr; }
|
.as-alert-card .as-tl-row { grid-template-columns: 52px 68px 1fr; }
|
||||||
.as-alert-card .as-tl-who { display: none; }
|
.as-alert-card .as-tl-who { display: none; }
|
||||||
.as-alert-card .as-alert-title { font-size: 18px; }
|
.as-alert-card .as-alert-title { font-size: 18px; }
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">본인 확인</h2>
|
<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" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||||
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
|
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
다시 서비스를 이용하시려면 <span>'장기미사용 제한 해제'</span>를 하셔야 이용하실 수 있습니다.</p>
|
다시 서비스를 이용하시려면 <span>'장기미사용 제한 해제'</span>를 하셔야 이용하실 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form_type">
|
<div class="form_type">
|
||||||
|
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
|
||||||
<form id="accountForm" role="form" name="accountForm" th:action="@{/dormant_account/verify}"
|
<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" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||||
<input type="hidden" id="mobileNumber" name="mobileNumber">
|
<input type="hidden" id="mobileNumber" name="mobileNumber">
|
||||||
<!-- 아이디 입력 -->
|
<!-- 아이디 입력 -->
|
||||||
|
|||||||
@@ -233,7 +233,12 @@
|
|||||||
customPopups.showAlert('입력 정보 확인');
|
customPopups.showAlert('입력 정보 확인');
|
||||||
} else {
|
} else {
|
||||||
refreshCsrfAndThen(form, function () {
|
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');
|
form.classList.add('was-validated');
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
<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}" />
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||||
<div class="register-form-container">
|
<div class="register-form-container">
|
||||||
<div class="info-notice-box">
|
<div class="info-notice-box">
|
||||||
@@ -127,16 +130,19 @@
|
|||||||
if (csrfToken && csrfHeader) {
|
if (csrfToken && csrfHeader) {
|
||||||
headers[csrfHeader.content] = csrfToken.content;
|
headers[csrfHeader.content] = csrfToken.content;
|
||||||
}
|
}
|
||||||
|
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
|
||||||
|
window.portalPasswordCrypto.encryptValues({ password: pw }).then(function (enc) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
|
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
data: { password: pw }
|
data: { password: enc.password }
|
||||||
}).done(function (res) {
|
}).done(function (res) {
|
||||||
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
|
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
|
||||||
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
|
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
|
||||||
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
|
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -160,11 +166,17 @@
|
|||||||
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
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({
|
TwoFactorAuth.open({
|
||||||
purpose: '/password/change',
|
purpose: '/password/change',
|
||||||
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
onSuccess: encryptThenSubmit,
|
||||||
onSuccess: function () { form.submit(); },
|
|
||||||
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
<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}" />
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||||
|
|
||||||
<div class="register-form-container">
|
<div class="register-form-container">
|
||||||
|
|||||||
@@ -390,11 +390,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
|
||||||
|
window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: /*[[@{/check_password}]]*/ '/check_password',
|
url: /*[[@{/check_password}]]*/ '/check_password',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: {
|
||||||
password: password,
|
password: enc.password,
|
||||||
mobileNumber: $('#mobileNumber').val(),
|
mobileNumber: $('#mobileNumber').val(),
|
||||||
loginId: $('#loginId').val(),
|
loginId: $('#loginId').val(),
|
||||||
_csrf: $('input[name="_csrf"]').val()
|
_csrf: $('input[name="_csrf"]').val()
|
||||||
@@ -411,6 +413,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 비밀번호 확인 검증
|
// 비밀번호 확인 검증
|
||||||
$('#confirmPassword').on('blur', function () {
|
$('#confirmPassword').on('blur', function () {
|
||||||
@@ -419,12 +422,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 두 값이 같은 AES 키로 각각 다른 IV 로 암호화되므로 서버에서 복호화 후 비교해야 한다.
|
||||||
|
window.portalPasswordCrypto.encryptValues({
|
||||||
|
password: $('#password').val(),
|
||||||
|
confirmPassword: confirmPassword
|
||||||
|
}).then(function (enc) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: /*[[@{/check_password_match}]]*/ '/check_password_match',
|
url: /*[[@{/check_password_match}]]*/ '/check_password_match',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: {
|
||||||
password: $('#password').val(),
|
password: enc.password,
|
||||||
confirmPassword: confirmPassword,
|
confirmPassword: enc.confirmPassword,
|
||||||
_csrf: $('input[name="_csrf"]').val()
|
_csrf: $('input[name="_csrf"]').val()
|
||||||
},
|
},
|
||||||
success: function (response) {
|
success: function (response) {
|
||||||
@@ -440,6 +448,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
|||||||
@@ -377,10 +377,13 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용하지 않도록 설정한 약관은 화면에 없으므로(관리 콘솔 > 약관 종류 관리) 없으면 통과 처리
|
||||||
|
const termsOfUseEl = document.getElementById('termsOfUse');
|
||||||
|
const privacyCollectEl = document.getElementById('privacyCollect');
|
||||||
const notificationConsentEl = document.getElementById('notificationConsent');
|
const notificationConsentEl = document.getElementById('notificationConsent');
|
||||||
const validations = {
|
const validations = {
|
||||||
termsOfUse: $('#termsOfUse').prop('checked'),
|
termsOfUse: !termsOfUseEl || termsOfUseEl.checked,
|
||||||
privacyPolicy: $('#privacyCollect').prop('checked'),
|
privacyPolicy: !privacyCollectEl || privacyCollectEl.checked,
|
||||||
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
|
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
|
||||||
compRegNo: $('#compRegNo').val().trim() !== '',
|
compRegNo: $('#compRegNo').val().trim() !== '',
|
||||||
corpRegNo: $('#corpRegNo').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');
|
const notificationConsentEl = document.getElementById('notificationConsent');
|
||||||
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
|
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
|
||||||
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
|
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
|
||||||
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
|
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
|
||||||
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
|
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
|
||||||
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
|
if (termsOfUseEl && !termsOfUseEl.checked) errors.push('이용약관에 동의해주세요.');
|
||||||
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
|
if (privacyCollectEl && !privacyCollectEl.checked) errors.push('개인정보 수집·이용에 동의해주세요.');
|
||||||
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
|
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
@@ -466,7 +471,10 @@
|
|||||||
console.log('Form validation passed, collecting form data...');
|
console.log('Form validation passed, collecting form data...');
|
||||||
const form = collectFormData();
|
const form = collectFormData();
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.submit();
|
// 전송암호화(봉투 치환) 후 제출. 시나리오별로 password 가 없을 수 있으나
|
||||||
|
// 존재하는 필드만 치환하므로 그대로 호출한다.
|
||||||
|
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
|
||||||
|
.then(function () { form.submit(); });
|
||||||
} else {
|
} else {
|
||||||
console.log('Form validation failed');
|
console.log('Form validation failed');
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="agreementForm" class="agreement-form">
|
<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">
|
<div class="agreement-all-section">
|
||||||
<label class="agreement-checkbox-label">
|
<label class="agreement-checkbox-label">
|
||||||
<input type="checkbox" id="agree_all" class="agreement-checkbox-input">
|
<input type="checkbox" id="agree_all" class="agreement-checkbox-input">
|
||||||
@@ -22,14 +29,16 @@
|
|||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<div class="agreement-divider"></div>
|
<div class="agreement-divider"></div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
<!-- Individual Agreements -->
|
<!-- Individual Agreements -->
|
||||||
<div class="agreement-items-list">
|
<div class="agreement-items-list">
|
||||||
<!-- Terms of Use -->
|
<!-- Terms of Use (사용하지 않도록 설정하면 숨김. 플래그가 없는 화면은 기존대로 노출) -->
|
||||||
|
<th:block th:if="${showTermsOfUse == null or showTermsOfUse}">
|
||||||
<div class="agreement-item-row">
|
<div class="agreement-item-row">
|
||||||
<label class="agreement-checkbox-label">
|
<label class="agreement-checkbox-label">
|
||||||
<input type="checkbox" name="termsOfUse" id="termsOfUse" class="agreement-checkbox-input"
|
<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-custom"></span>
|
||||||
<span class="agreement-checkbox-text">
|
<span class="agreement-checkbox-text">
|
||||||
<span class="agreement-required">[필수]</span>
|
<span class="agreement-required">[필수]</span>
|
||||||
@@ -46,15 +55,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
|
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
|
||||||
<button type="button" class="btn-action-primary md agreement-agree-btn"
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Privacy Policy -->
|
</th:block>
|
||||||
|
|
||||||
|
<!-- Privacy Collect (사용하지 않도록 설정하면 숨김. 플래그가 없는 화면은 기존대로 노출) -->
|
||||||
|
<th:block th:if="${showPrivacyCollect == null or showPrivacyCollect}">
|
||||||
<div class="agreement-item-row">
|
<div class="agreement-item-row">
|
||||||
<label class="agreement-checkbox-label">
|
<label class="agreement-checkbox-label">
|
||||||
<input type="checkbox" name="privacyCollect" id="privacyCollect"
|
<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>
|
<span class="agreement-checkbox-custom"></span>
|
||||||
<th:block th:switch="${registrationType}">
|
<th:block th:switch="${registrationType}">
|
||||||
<span class="agreement-checkbox-text" th:case="'personal'">
|
<span class="agreement-checkbox-text" th:case="'personal'">
|
||||||
@@ -78,16 +90,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
|
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
|
||||||
<button type="button" class="btn-action-primary md agreement-agree-btn"
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notification Consent (모델에 notificationConsent가 있을 때만 노출) -->
|
</th:block>
|
||||||
<th:block th:if="${notificationConsent != null}">
|
|
||||||
|
<!-- Notification Consent (사용하지 않도록 설정했거나 내용이 없으면 숨김) -->
|
||||||
|
<th:block th:if="${(showNotificationConsent == null or showNotificationConsent) and notificationConsent != null}">
|
||||||
<div class="agreement-item-row">
|
<div class="agreement-item-row">
|
||||||
<label class="agreement-checkbox-label">
|
<label class="agreement-checkbox-label">
|
||||||
<input type="checkbox" name="notificationConsent" id="notificationConsent"
|
<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-custom"></span>
|
||||||
<span class="agreement-checkbox-text">
|
<span class="agreement-checkbox-text">
|
||||||
<span class="agreement-required">[필수]</span>
|
<span class="agreement-required">[필수]</span>
|
||||||
@@ -105,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
|
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
|
||||||
<button type="button" class="btn-action-primary md agreement-agree-btn"
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -276,6 +290,12 @@
|
|||||||
row.style.cursor = 'pointer';
|
row.style.cursor = 'pointer';
|
||||||
row.addEventListener('click', function (e) {
|
row.addEventListener('click', function (e) {
|
||||||
if (e.target.closest('.agreement-checkbox-input') || e.target.closest('.agreement-checkbox-custom')) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const toggleIcon = this.querySelector('.agreement-toggle-icon');
|
const toggleIcon = this.querySelector('.agreement-toggle-icon');
|
||||||
|
|||||||
@@ -122,8 +122,9 @@
|
|||||||
isPasswordValid &&
|
isPasswordValid &&
|
||||||
isPasswordMatch &&
|
isPasswordMatch &&
|
||||||
userNameElement.val().trim() !== '' &&
|
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')) &&
|
(notificationConsentElement.length === 0 || notificationConsentElement.prop('checked')) &&
|
||||||
mobileNumberElement.val().trim() !== '' &&
|
mobileNumberElement.val().trim() !== '' &&
|
||||||
isAuthVerified;
|
isAuthVerified;
|
||||||
@@ -185,7 +186,9 @@
|
|||||||
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
|
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
|
||||||
form.appendChild(authCompletedField);
|
form.appendChild(authCompletedField);
|
||||||
|
|
||||||
form.submit();
|
// 전송암호화(봉투 치환) 후 제출. 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
|
||||||
|
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
|
||||||
|
.then(function () { form.submit(); });
|
||||||
} else {
|
} else {
|
||||||
customPopups.showAlert(!isAuthVerified
|
customPopups.showAlert(!isAuthVerified
|
||||||
? '인증번호확인을 완료해주세요.'
|
? '인증번호확인을 완료해주세요.'
|
||||||
|
|||||||
@@ -41,10 +41,11 @@
|
|||||||
<div class="search-filter-row">
|
<div class="search-filter-row">
|
||||||
<!-- 일별: 날짜 범위 -->
|
<!-- 일별: 날짜 범위 -->
|
||||||
<div class="date-range-picker" id="dailyFilter">
|
<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')}">
|
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
|
||||||
<span class="date-separator">-</span>
|
<input type="hidden" id="endDate"
|
||||||
<input type="date" id="endDate" class="date-input"
|
|
||||||
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<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>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<path d="m21 21-4.35-4.35"></path>
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
@@ -335,6 +336,8 @@
|
|||||||
let currentMode = 'DAILY';
|
let currentMode = 'DAILY';
|
||||||
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
||||||
const MAX_DATE_RANGE_DAYS = 40;
|
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세그먼트) ──
|
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
@@ -460,16 +463,70 @@
|
|||||||
return meta ? meta.getAttribute('content') : '';
|
return meta ? meta.getAttribute('content') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate date range (max 40 days)
|
// 서버 기준 1년 전 ~ 오늘 중 최대 40일만 조회한다 (양 끝 포함).
|
||||||
function validateDateRange(startDate, endDate) {
|
function validateDateRange(startDate, endDate) {
|
||||||
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||||
var start = new Date(startDate), end = new Date(endDate);
|
var start = moment(startDate, 'YYYY-MM-DD', true), end = moment(endDate, 'YYYY-MM-DD', true);
|
||||||
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
if (!start.isValid() || !end.isValid()) return { valid: false, message: '올바른 날짜를 선택해주세요.' };
|
||||||
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
|
if (start.isAfter(end)) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||||
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
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 };
|
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() {
|
function buildPayload() {
|
||||||
return {
|
return {
|
||||||
mode: currentMode,
|
mode: currentMode,
|
||||||
@@ -505,8 +562,10 @@
|
|||||||
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
||||||
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
||||||
}
|
}
|
||||||
if (p.startDate) $('#startDate').val(p.startDate);
|
if (validateDateRange(p.startDate, p.endDate).valid) {
|
||||||
if (p.endDate) $('#endDate').val(p.endDate);
|
$('#startDate').val(p.startDate);
|
||||||
|
$('#endDate').val(p.endDate);
|
||||||
|
}
|
||||||
if (p.month) $('#month').val(p.month);
|
if (p.month) $('#month').val(p.month);
|
||||||
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
||||||
return true;
|
return true;
|
||||||
@@ -563,12 +622,12 @@
|
|||||||
// Event Handlers
|
// Event Handlers
|
||||||
$('#btnSearch').on('click', searchStatistics);
|
$('#btnSearch').on('click', searchStatistics);
|
||||||
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
|
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
|
||||||
$('#startDate, #endDate').on('change', searchStatistics);
|
|
||||||
$('#month').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();
|
searchStatistics();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<button type="button" class="btn btn-primary" id="addUserBtn">
|
<button type="button" class="btn btn-primary" id="addUserBtn">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
<span>개발자 추가</span>
|
<span>개발자 초대</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
th:if="${user.userStatus.toString() == 'PENDING'}"
|
th:if="${user.userStatus.toString() == 'PENDING'}"
|
||||||
th:data-user-id="${user.id}"
|
th:data-user-id="${user.id}"
|
||||||
th:data-user-email="${user.maskedEmailAddr}">
|
th:data-user-email="${user.maskedEmailAddr}">
|
||||||
초대취소
|
초대 취소
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Action Dropdown (Mobile) -->
|
<!-- Action Dropdown (Mobile) -->
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
th:if="${user.userStatus.toString() == 'PENDING'}"
|
th:if="${user.userStatus.toString() == 'PENDING'}"
|
||||||
th:data-user-id="${user.id}"
|
th:data-user-id="${user.id}"
|
||||||
th:data-user-email="${user.maskedEmailAddr}">
|
th:data-user-email="${user.maskedEmailAddr}">
|
||||||
초대취소
|
초대 취소
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
<button type="button" class="list-table-btn list-table-btn--secondary cancel_invitation"
|
<button type="button" class="list-table-btn list-table-btn--secondary cancel_invitation"
|
||||||
th:data-user-id="${user.id}"
|
th:data-user-id="${user.id}"
|
||||||
th:data-user-email="${user.maskedMobileNumber}">
|
th:data-user-email="${user.maskedMobileNumber}">
|
||||||
초대취소
|
초대 취소
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile Dropdown -->
|
<!-- Mobile Dropdown -->
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<button type="button" class="dropdown-item cancel_invitation"
|
<button type="button" class="dropdown-item cancel_invitation"
|
||||||
th:data-user-id="${user.id}"
|
th:data-user-id="${user.id}"
|
||||||
th:data-user-email="${user.maskedMobileNumber}">
|
th:data-user-email="${user.maskedMobileNumber}">
|
||||||
초대취소
|
초대 취소
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event listeners to the "개발자 추가" buttons
|
// Add event listeners to the "개발자 초대" buttons
|
||||||
const addUserBtn = document.getElementById('addUserBtn');
|
const addUserBtn = document.getElementById('addUserBtn');
|
||||||
|
|
||||||
if (addUserBtn) {
|
if (addUserBtn) {
|
||||||
|
|||||||
@@ -166,10 +166,13 @@
|
|||||||
function post(url, password) {
|
function post(url, password) {
|
||||||
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||||
headers[CSRF_HEADER] = CSRF_TOKEN;
|
headers[CSRF_HEADER] = CSRF_TOKEN;
|
||||||
|
// 전송암호화. 꺼져 있거나 브라우저가 지원하지 못하면 원본 값이 그대로 돌아온다.
|
||||||
|
return window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: 'password=' + encodeURIComponent(password)
|
body: 'password=' + encodeURIComponent(enc.password)
|
||||||
|
});
|
||||||
}).then(function (r) { return r.json(); });
|
}).then(function (r) { return r.json(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
||||||
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
||||||
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
|
- 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) 재사용.
|
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
|
||||||
*/-->
|
*/-->
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
|
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
|
||||||
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
||||||
|
window.API_SELECTOR_PAGE_SIZE = /*[[${@apiListProperties.pageSize}]]*/ 15;
|
||||||
</script>
|
</script>
|
||||||
<script th:src="@{/js/api-selector.js}"></script>
|
<script th:src="@{/js/api-selector.js}"></script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|||||||
@@ -32,6 +32,20 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</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에서 토큰·헤더명을 읽어 사용한다. -->
|
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
||||||
|
|||||||
@@ -5,33 +5,25 @@
|
|||||||
<section layout:fragment="title">
|
<section layout:fragment="title">
|
||||||
<div class="page-title-banner">
|
<div class="page-title-banner">
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
<h1 th:text="${isTermsOfUse ? '이용약관' : (isPrivacyCollect ? '개인정보수집동의서' : '알림 수신 동의서')}">이용 약관</h1>
|
<h1 th:text="${agreementTitle} ?: '약관'">이용 약관</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section layout:fragment="contentFragment">
|
<section layout:fragment="contentFragment">
|
||||||
<div class="terms-container">
|
<div class="terms-container">
|
||||||
<!-- Title Bar -->
|
<!-- Title Bar -->
|
||||||
<div class="common-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>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation : 노출 대상과 순서는 관리 콘솔 '약관 종류 관리'에서 설정한다 -->
|
||||||
<div class="terms-tabs">
|
<div class="terms-tabs" th:if="${not #lists.isEmpty(termsTabs)}">
|
||||||
<a th:href="@{/agreements/terms(tab='terms')}"
|
<a th:each="termsTab : ${termsTabs}"
|
||||||
|
th:href="@{/agreements/terms(tab=${termsTab.tab})}"
|
||||||
class="tab-link"
|
class="tab-link"
|
||||||
th:classappend="${isTermsOfUse ? 'active' : ''}">
|
th:classappend="${termsTab.active ? 'active' : ''}"
|
||||||
|
th:text="${termsTab.title}">
|
||||||
이용약관
|
이용약관
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Version Selector -->
|
<!-- Version Selector -->
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<!-- Modal Header -->
|
<!-- Modal Header -->
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 class="modal-title">초대취소</h3>
|
<h3 class="modal-title">초대 취소</h3>
|
||||||
<button type="button" class="modal-close" id="cancelInvitationPopupCloseButton">
|
<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">
|
<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>
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
<strong id="cancelInvitationTarget" style="color: #4B9BFF;"></strong>에 대한<br>초대를 취소하시겠습니까?
|
<strong id="cancelInvitationTarget" style="color: #4B9BFF;"></strong>에 대한<br>초대를 취소하시겠습니까?
|
||||||
</p>
|
</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"
|
<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;">
|
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;">
|
<input type="checkbox" id="cancelInvitationNotifyConsent" style="margin-top: 3px; flex-shrink: 0;">
|
||||||
@@ -37,12 +37,20 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Footer -->
|
<!-- Modal Footer -->
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary btn-submit" id="cancelInvitationPopupCancelButton">닫기</button>
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<!-- fragment/popup/customPopups.html -->
|
<!-- fragment/popup/customPopups.html -->
|
||||||
<div th:fragment="customPopups">
|
<div th:fragment="customPopups">
|
||||||
<div th:replace="fragment/popup/customPopup :: #customAlert"></div>
|
<div th:replace="~{fragment/popup/customPopup :: #customAlert}"></div>
|
||||||
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
|
<div th:replace="~{fragment/popup/emailValidationPopup :: #emailValidationPopup}"></div>
|
||||||
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
|
<div th:replace="~{fragment/popup/customPopup2 :: #customConfirm}"></div>
|
||||||
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
|
<div th:replace="~{fragment/popup/passwordInputPopup :: passwordInputPopup}"></div>
|
||||||
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
|
<div th:replace="~{fragment/popup/terminateRequestPopup :: terminateRequestPopup}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<!-- Modal Header -->
|
<!-- Modal Header -->
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 class="modal-title">개발자 추가</h3>
|
<h3 class="modal-title">개발자 초대</h3>
|
||||||
<button type="button" class="modal-close" id="userInvitePopupCloseButton">
|
<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">
|
<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>
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<!-- Modal Body -->
|
<!-- Modal Body -->
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p id="userInvitePopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
|
<p id="userInvitePopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
|
||||||
추가할 개발자 휴대폰 번호를 입력해 주세요.
|
초대할 개발자 휴대폰 번호를 입력해 주세요.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Mobile Input Field -->
|
<!-- Mobile Input Field -->
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
<div id="userInvitePopupError" class="error-message"></div>
|
<div id="userInvitePopupError" class="error-message"></div>
|
||||||
</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"
|
<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;">
|
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;">
|
<input type="checkbox" id="userInviteNotifyConsent" style="margin-top: 3px; flex-shrink: 0;">
|
||||||
@@ -47,6 +47,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Footer -->
|
<!-- Modal Footer -->
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
|
|
||||||
<head th:replace="fragment/head :: headFragment">
|
<head th:replace="~{fragment/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
<head th:replace="fragment/djbank/head :: headFragment">
|
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -20,9 +20,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
|
||||||
<section layout:fragment="pagePopups"></section>
|
<section layout:fragment="pagePopups"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
|
|
||||||
<head th:replace="fragment/djbank/head :: headFragment">
|
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||||
|
|
||||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
<head th:replace="fragment/djbank/head :: headFragment">
|
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<th:block layout:fragment="script">
|
<th:block layout:fragment="script">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
<head th:replace="fragment/djbank/head :: headFragment">
|
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="loading-overlay" style="display: none;">
|
<div class="loading-overlay" style="display: none;">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="navigation" th:replace="fragment/djbank/header_nav :: headerFragment"></div>
|
<div class="navigation" th:replace="~{fragment/djbank/header_nav :: headerFragment}"></div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
|
||||||
<section layout:fragment="pagePopups"></section>
|
<section layout:fragment="pagePopups"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
|
|
||||||
<head th:replace="fragment/head :: headFragment">
|
<head th:replace="~{fragment/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,31 +3,31 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
|
|
||||||
<head th:replace="fragment/head :: headFragment">
|
<head th:replace="~{fragment/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- header -->
|
<!-- header -->
|
||||||
<header th:replace="fragment/admin_header :: headerFragment"></header>
|
<header th:replace="~{fragment/admin_header :: headerFragment}"></header>
|
||||||
<th:block th:replace="fragment/admin_header :: headerScript"></th:block>
|
<th:block th:replace="~{fragment/admin_header :: headerScript}"></th:block>
|
||||||
<nav th:replace="fragment/header :: navFragment"></nav>
|
<nav th:replace="~{fragment/header :: navFragment}"></nav>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- csrf -->
|
<!-- csrf -->
|
||||||
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<div>
|
<div>
|
||||||
<aside th:replace="fragment/menu :: menuFragment"></aside>
|
<aside th:replace="~{fragment/menu :: menuFragment}"></aside>
|
||||||
<th:block th:replace="fragment/menu :: menuScript"></th:block>
|
<th:block th:replace="~{fragment/menu :: menuScript}"></th:block>
|
||||||
|
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<div layout:fragment="contentFragment">
|
<div layout:fragment="contentFragment">
|
||||||
</div>
|
</div>
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
<h2>2. 프래그먼트 대체</h2>
|
<h2>2. 프래그먼트 대체</h2>
|
||||||
<header th:replace="fragment/header :: headerFragment"></header>
|
<header th:replace="~{fragment/header :: headerFragment}"></header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user