194 Commits

Author SHA1 Message Date
Rinjae 59c379b22c 비밀번호 전송 암호화 기능 추가:
- RSA-OAEP + AES-GCM 기반 비밀번호 전송 암호화 모듈 구현
- 클라이언트 공개키 발급 및 복호화 처리 로직 개발
- 암호화 설정 속성 및 요청별/세션별/서버별 키 보관소 구성
2026-09-01 15:06:30 +09:00
Rinjae c732f1e1b2 API 상태 타임라인 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 진행 상태에 따른 상태 색 및 한글 라벨 표시 추가
2026-09-01 14:12:17 +09:00
Rinjae 9e32502ee3 WebLogic 라이브러리 안정성 검증 로직 강화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- jboss-logging/Guava 클래스 로드시충돌 점검 추가
- ReadinessController에 관련 체크 로직 반영
2026-08-27 16:47:15 +09:00
Rinjae 2d0ff2bf84 템플릿 엔진 상태 점검 추가 - Thymeleaf 구성 검증 로직 반영
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:44:57 +09:00
Rinjae 21940a3e18 WebLogic jackson 버전 충돌 방지 설정 추가 - jackon-module-jaxb-annotations 명시 의존성 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:40:05 +09:00
Rinjae 2e517a1e08 WebLogic 라이브러리 4개 우선 로드 설정 추가 - IllegalAccessError/NoSuchMethodError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:25:30 +09:00
Rinjae 10c5e51162 WebLogic commons-lang3 우선 로드 설정 추가 - IllegalAccessError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 16:20:24 +09:00
Rinjae 69e5126de6 WebLogic commons-fileupload 우선 로드 설정 추가 - NoSuchMethodError 방지
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 15:55:37 +09:00
Rinjae d044ebd157 Merge branch 'feats/security'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 15:43:42 +09:00
Rinjae af40f0ba61 비밀번호 길이 검증 제거 - 로그인 스크립트 단순화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-27 15:11:39 +09:00
Rinjae d6b3a44d61 Merge branch 'master' into feats/security
# Conflicts:
#	src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java
#	src/main/resources/templates/views/apps/webhook/webhookList.html
#	src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
#	src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
2026-08-27 13:23:15 +09:00
Rinjae e57f2fe72f WIP: webhook 작업 임시 커밋 (develop 전환 전 안전 확보) 2026-08-27 10:59:22 +09:00
Rinjae cecf6260a6 서비스 페이지 스타일 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모바일 화면에서 hero-illustration 순서 수정 (우선 순위 -1)
- `_service.scss` 변경사항에 따른 CSS 소스맵(main.css.map) 업데이트
2026-08-27 10:52:31 +09:00
Rinjae 52707998d4 Swagger UI 응답 처리 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 응답 렌더링 중 "응답지우기" 클릭 시 패널 복구 방지 로직 추가
- "Clear" 버튼 동작 확장: 사용자 정의 응답 그리드 제거 처리
2026-08-26 19:28:38 +09:00
Rinjae 28017fd83c 테스트 코드 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- API 상태 관련 서비스 테스트 클래스 신규 작성 (총 7개)
- 테스트 코드에서 `visibleApiIds` 필터 적용 확인 및 조회 로직 검증 포함
- `TestCleanupService` 확장: 그룹 제한 데이터 관련 신규 Fixture 및 Mock 추가
2026-08-26 17:44:48 +09:00
Rinjae-gf63 e55b7a6bf2 - 본인확인 비밀번호 실패 관련 메시지 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 계정 차단/로그아웃 메시지 보안 표현 강화
- 로그 및 계정 차단 사유 메시지 가독성 개선
2026-08-26 17:10:13 +09:00
Rinjae 1b6fed7701 CSS 소스맵(main.css.map) 누락된 소스 경로 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-26 13:10:19 +09:00
Rinjae 033faa6714 알림 테스트 도구 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- SCSS 스타일 및 HTML 템플릿 구현
- 알림 발송/상태 처리 자바 로직 추가
- 클라이언트 JS: 동적 데이터 바인딩, 발송/이력 관리
2026-08-26 12:57:03 +09:00
Rinjae 758b3ebd7d 알림수신동의서 및 개인정보수집동의서 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 새 HTML 및 TXT 파일 작성
- 서비스 이용 약관 신규 추가 및 HTML/TXT 생성
2026-08-26 11:11:17 +09:00
Rinjae f07123904a 브랜드명 템플릿화 적용:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 다국어 메시지 파일(title.html) `{brand.name}` 사용으로 수정
- HTML 제목 태그 내 문자열 치환 로직 추가
2026-08-26 10:05:21 +09:00
Rinjae 2bc2a3d6e3 "신규 신청 쿨다운 로직 제거 - Repository/Service/Controller 코드 정리 - 불필요한 HTML 스크립트 제거"
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-26 09:43:00 +09:00
Rinjae b58893d098 계정 보안 구현 - 연속 실패 시 계정 잠금 서비스 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 비밀번호 본인확인 연속 실패 정책 및 처리 로직 추가
- 작성 요청 빈도 제한 서비스 및 메시지 구현
- 키보드 연속 문자 검증 규칙 및 테스트 추가
2026-08-25 20:24:46 +09:00
Rinjae 80b072983f Webhook URL 형식 검증 로직 분리 및 재사용성 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- WebhookUrlValidator 추가: URL 형식 검증 전담
- Service/Controller에서 공통 Validator 활용하도록 수정
2026-08-25 15:39:00 +09:00
Rinjae a456ed9bb0 사용자 Secret 관리 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 사용자 지정 Secret 필드 추가(WebhookRequest, DTO, Mapper)
- 사용자 Secret 평문 조회/갱신 로직 및 비밀번호 재인증 처리
- Secret 입력 UI 개선 및 스타일 수정
2026-08-25 15:16:57 +09:00
Rinjae 33c64972f8 - 브랜드명 템플릿화 적용 - 정적 리소스/HTML 다수 수정
- 서비스 소개 페이지 디자인/스타일 대폭 개선
- 웹훅/OAuth2 개발 가이드 문구 및 텍스트 업데이트/템플릿화
2026-08-25 13:42:30 +09:00
Rinjae cff0199d2d TestCleanupService 확장 - FileService/PasswordEncoder 추가 Mock 설정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-24 21:16:23 +09:00
Rinjae 2e920eec96 4010-Q&A 테스트 계정 준비 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 법인개발자 계정 소속/비밀번호 초기화(`ensureTestCorpDeveloper`)
- 비밀번호만 재설정하는 테스트 API 추가(`resetTestPassword`)
- Q&A 테스트 문서 및 시나리오 수정(계정 준비 단계 8-3 추가)
2026-08-24 18:45:50 +09:00
Rinjae 65a262849e - 4020-partnership 테스트 추가 - 등록/조회/삭제 시나리오 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- e2e/admin-partnership.page.ts - admin 접근 및 확인 로직 작성
- e2e/partnership-feedback.spec.ts - e2e 테스트 케이스 작성
2026-08-24 17:51:36 +09:00
Rinjae 7056306718 - 파트너십 글 단건 조회/삭제 메서드 추가 및 본인 확인 로직 적용
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- TestCleanupService 확장 - 테스트 글 삭제 및 첨부파일 정리 추가
- 레이아웃 개선 - footer 하단 고정(sticky) 처리
2026-08-24 17:21:47 +09:00
Rinjae 05e0599b26 - AppRequestRepository에 findAllByOrgAndClientName 메서드 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 클라이언트 신청 해지 프로세스 및 테스트 클린업 로직 구현
- Playwright API 키 관리/클라이언트 관리 경로 및 셀렉터 업데이트
2026-08-24 15:25:18 +09:00
Rinjae c223fa3124 PasswordServiceTest 추가 - 최근 비밀번호 중복 검사 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
TestCleanupService 확장 - 초대/회원 소속 관리 메서드 구현
API 추가 - 초대 삭제 및 소속 제외, 사용자 존재 여부 확인
2026-08-24 14:13:26 +09:00
Rinjae 78b1edff5a - 인증번호 재발송 제한 메시지 개선 - 남은 초 표시 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 비밀번호 확인 필드명 일괄 변경(password2 → confirmPassword)
- 법인 관리자 탈퇴 제한 경고 메시지 추가 및 관련 로직 수정
- 임시 비밀번호 발급 시 기존 비밀번호 이력 저장 로직 추가
2026-08-24 13:17:14 +09:00
Rinjae b41a81bda3 Webhook API 표시 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- WebhookDTO에 API 이름 포함 필드 추가(WebhookApiDTO)
- 프로필 활성화 뱃지 CSS 수정(고정 위치, 크기 축소) - 정적 자원 서빙 경로 설정 개선(staticBase 적용)
2026-08-24 10:37:12 +09:00
Rinjae 1ea2c89776 - 인덱스 통계 API 이용 건수 로직 수정 - 월누적 호출 건수 반영
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GwAuthClient 리포지토리 다건 org용 메서드 추가
- 통계 DTO 주석 수정 및 관련 메서드 추가
2026-08-21 18:07:06 +09:00
Rinjae f1d6181210 - Playwright 테스트 정리용 internal API 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- /internal/test-cleanup/{org,user,orphans} 신설
- dev 프로필 전용, 토큰+IP allowlist 이중 가드
- 13개 고아 테이블 cascade/스윕 삭제 서비스
- 리포지토리 deleteBy 메서드 추가, 단위테스트 포함
2026-08-21 17:57:51 +09:00
Rinjae bac9a54dee 활성 프로파일 뱃지 추가 - HTTP 세션 로그 설정 제거 - 개인정보 암호화 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-21 17:25:41 +09:00
Rinjae 603cba65a7 API 목록 페이징 기능 추가 - 검색 결과 페이지 처리 로직 구현
API 아이콘 스트리밍 엔드포인트 추가 - base64 데이터 URL 캐싱 처리
response 압축 설정 추가 - application.yml에 설정 값 정의
2026-08-21 10:48:47 +09:00
Rinjae 889d77b990 로그 설정 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- JVM 파라미터로 파일, 세션, API 테스터 로그 레벨 제어 추가
- 루트, 세션, API 테스터 로그 레벨 동적 설정 지원
2026-08-20 14:34:01 +09:00
Rinjae bf2dad6ba7 고객센터 연락처 미노출 조건 변경 - 문자열 공백 검사 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 18:47:54 +09:00
Rinjae 3a1b0cb9c1 고객센터 연락처 미노출 조건 변경 - 문자열 공백 검사 함수 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 18:25:19 +09:00
Rinjae a13279a777 고객센터 연락처 미노출 조건 추가 - 값 공백 시 th:if로 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 16:46:58 +09:00
Rinjae 5299352235 CSS hover 색상값 표현 변경 (RGB → 퍼센트 형식)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 15:17:41 +09:00
Rinjae dfb37c18b9 commons-lang3 3.13 → 3.13.1 업그레이드와 코드 최적화
- Strings API로 대체 및 StringUtils 불필요 메서드 제거
- CellPhoneValidator 시작 번호 체크 로직 개선
2026-08-18 17:48:58 +09:00
Rinjae 5f5b23026f JSON 및 파일 처리 라이브러리 상향 회귀 테스트 추가
- commons-lang3 3.20.0 업그레이드로 문자열 처리 유지 검증
- 파트 헤더 설정 증가(CVE-2025-48976) 회귀 확인 테스트 추가
- jackson-bom 2.18.10 업그레이드 후 날짜 직렬화 검증
2026-08-18 17:07:21 +09:00
Rinjae 5c61fc3f24 멀티파트 업로드 및 의존성 보안 업데이트
- MultipartConfig: partHeaderSizeMax 설정 추가(CVE-2025-48976 대응)
- build.gradle: commons-fileupload 1.6.0 및 commons-beanutils 1.11.0 업그레이드
2026-08-18 16:56:26 +09:00
Rinjae a7aa5bc98c Spring Boot/Framework/Security 관련 CVE 억제 규칙 추가 및 구성 수정
- dependency-check-suppressions.xml: 오탐 및 비사용 기능 CVE 5건 억제 추가
- PortalConfigSecurity.java: CVE-2026-22732 대응 우회 로직 추가
- build.gradle: Spring 의존성 버전 정리 및 중복 핀 제거
2026-08-18 15:18:08 +09:00
Rinjae bf63701e68 리다이렉트 경로 상수화 및 중복 제거
- "redirect:/", "redirect:/login?reason=auth" 상수로 치환
- 중복된 문자열 정의 제거 및 가독성 개선
2026-08-18 14:50:08 +09:00
Rinjae f26aae296a ECharts 테마 파일 및 ChartEditor.js 제거 - 불필요 리소스 삭제 2026-08-18 14:44:06 +09:00
Rinjae 8b4bc425ec - build.gradle: damo-manager 의존성 scope 변경(compileOnly → implementation)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- application.yml: 로그 경로 수정(/logs/eapim → /logs/prod/eapim)
2026-08-14 19:05:29 +09:00
Rinjae 06dc148515 Lodash 업데이트 및 보안 강화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Lodash 버전 4.17.21 → 4.18.1로 업데이트
- prototype pollution 방지 로직 추가
- `_.template` 관련 보안 경고 및 문서 갱신
2026-08-14 17:48:45 +09:00
Rinjae 3cfd6b0b04 Atomikos JTA 제거: 로컬 트랜잭션 전환 및 관련 설정 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- JTA/XA 미사용으로 변경: JpaTransactionManager로 전환
- Atomikos 의존성 및 설정 제거, 관련 문서 및 주석 업데이트
2026-08-14 17:11:54 +09:00
Rinjae 7a0f88bfb1 Atomikos 설정 변경: max-actives 속성 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-14 16:50:10 +09:00
Rinjae 6d7b292b8a 운영 인증번호 테스트 종료일 판정 클래스 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- TestNoticeWindow: 운영 환경에서 인증번호 노출 종료일 유효성 검증
- TwoFactorProperties/AuthNoticeProperties에 종료일 기반 처리 로직 통합
2026-08-14 13:13:20 +09:00
Rinjae 20ad6f8394 Thymeleaf 템플릿 JS 분석 제외 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- sonar.javascript.exclusions 속성에 템플릿 경로 추가
- XSS 룰 유지 위해 HTML 분석은 제외하지 않음
2026-08-13 18:18:09 +09:00
Rinjae 2bae0e350c OWASP Dependency-Check 통합 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Jenkinsfile.security: 라이브러리 취약점 검사용 파이프라인 작성 (SAST/SCA 병행)
- dependency-check-classpath.gradle: 스캔 대상 의존성 jar 분리 로직 작성
- dependency-check-suppressions.xml: 오탐 억제 규칙 파일 추가
2026-08-13 17:53:14 +09:00
Rinjae 45e8fa2903 레거시 암호화 컨트롤러 제거 - 내부 API 토큰 초기화 로직 추가 - 정규식 성능 최적화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-13 16:52:04 +09:00
Rinjae 0b6ee10439 Jenkinsfile.sonar: SonarQube 설정 이름 보정 및 스캐너 탐색 폴백
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- SONAR_ENV_NAME 을 실제 등록명 SonarQube-Community 로 수정
- 스캐너 툴 이름 미지정 시 후보 이름 탐색 후 PATH 의 sonar-scanner 폴백
- 스캐너를 못 찾으면 원인이 드러나는 메시지와 함께 실패

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:29:21 +09:00
Rinjae 1e21b85de1 - 소수 자릿수 처리 개선 - DECIMAL/BIGDECIMAL 정수/숫자 구분
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 샘플 생성 로직 리팩토링 - 길이 및 소수 자릿수 기준 상세화
- 공통 스키마 처리 함수 추가 - 코드 중복 제거 및 가독성 향상
2026-08-13 14:42:44 +09:00
Rinjae 7b8e0575c5 Actuator 인터셉터 엔드포인트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `/actuator/mappings`에 누락된 Interceptor 목록 읽기용 엔드포인트 구현
- 내부 사용자 이메일 도메인 동적 주입 로직 UserTypeConfigurer 클래스에 추가
- 비동기 스타일 개선 및 CSS 메인 스타일 일부 수정
2026-08-13 13:36:09 +09:00
Rinjae 7c0cd371f0 클라이언트 표현 수정 및 내부 사용자 이메일 도메인 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- "앱" -> "클라이언트"로 명칭 일괄 수정 (HTML/DTO/JS)
- 내부 사용자 이메일 도메인 동적 설정 로직 및 기본값 추가
2026-08-13 12:50:50 +09:00
Rinjae eb2cce8002 Actuator/SBA 로컬 제한 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- application.yml 주석 및 build.gradle 로컬 전용 라이브러리 처리
- 배포 산출물에서 로컬 전용 리소스/라이브러리 제외
2026-08-12 11:48:37 +09:00
Rinjae 634c97c7ca 첨부파일 업로드 제약 조건 추가 및 APIM 서비스 파일 컨텍스트 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 첨부파일 업로드 시 파일 크기/형식 조건 동적 적용
- FileTypeContext.clear 호출 추가, 내부 사용자 컨텍스트 정리
2026-08-12 09:41:59 +09:00
Rinjae e32acfa215 UserLog 엔티티 필드 추가 및 관련 API User-Agent 처리 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- UserLog에 userAgent(500자)와 duplicate(Boolean) 컬럼 추가
- 로그인/실패/로그아웃 로깅 시 User-Agent와 중복 세션 여부 기록
- TwoFactorService/Controller Request 객체 전달 및 처리 반영
2026-08-11 19:10:39 +09:00
Rinjae 8356fc3029 - Swagger spec 서버 주소 GW 기준 치환 통합 - UI/다운로드 동일 기준
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- ApiTesterFilter: gw 호출 시 base-url 재조립 처리 추가
- admin 스펙 저장 시 실주소 고정 제거 - 재치환 유연성 확보
2026-08-11 19:00:18 +09:00
Rinjae d6186de009 DEFAULT spec 서버 치환 제거:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GW/sentinel 치환 로직 삭제 및 serverRewriter 호출 제거
- mock path 분리/servers 덮어쓰기 로직 및 공통 유틸 추가
2026-08-11 16:56:17 +09:00
Rinjae 2a9980ab16 - API Tester 프록시 응답 헤더 전달 로직 개선 - BLOCKED 헤더 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- API ID 기반 명세 조회 기능 추가 - GW/Mock path 매칭 문제 해결
- APISender에 ApiResponse 클래스 도입 - 응답 상태코드/본문/헤더 반환
2026-08-11 16:38:46 +09:00
Rinjae 919b4a0b06 이메일 인증 템플릿 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 제주은행/DJ Bank 이메일 템플릿 신규 추가
- Base64 임베디드 이미지 및 링크형 리소스 분리
2026-08-11 15:03:37 +09:00
Rinjae b0ecb26a9d 변경/해지 신청 개선 및 사전검사 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 변경/해지 상태 표시 및 취소 기능 추가
- 해지 신청 사전검사 및 경고 메시지 처리 구현
- 관리자 승인 이전 허용되는 취소 정책 지원
2026-08-11 14:15:33 +09:00
Rinjae 0ad5bffa4f DJB 커스텀 소스 납품 스크립트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Windows, Linux/macOS 지원 배치/셸 스크립트 구현
- 소스 필터 규칙 및 수집 파일/모듈 매니페스트 작성
2026-08-11 11:47:31 +09:00
Rinjae 766341540d 개발가이드 Base URL 동적 처리, Mock 응답 인증 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GuideProperty 이용 개발가이드 GW URL 동적 주입
- sample/mock 응답 유형의 인증 절차 생략 로직 추가
- Webhook 이벤트 코드명/JSON 값 수정
2026-08-11 11:46:49 +09:00
Rinjae 63d4d7268c Merge branch 'master' into design
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-07 10:19:52 +09:00
Rinjae c42a278c8e 수신자 이름 지정 인증번호 발송 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- %USER_NAME% 지원 위한 createMessageRecipient 수정
- GUEST_USER_NAME 상수 및 관련 기본값 처리 로직 추가
2026-08-07 09:20:39 +09:00
hong 83352e3669 fix : css 수정 2026-08-06 13:21:41 +09:00
hong 317b8d781d Merge branch 'design' of http://172.30.1.50:3000/djb-eapim/eapim-portal into design 2026-08-06 13:20:27 +09:00
hong 206e72edde fix : 디자인 수정 2026-08-06 13:20:21 +09:00
Rinjae 34dce308dc 메뉴 내부 API 허용 IP 검증 로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- IpAddressMatcher로 CIDR/와일드카드 지원
- 허용 IP 목록 설명 필드 추가
- RemoteAddr 정규화 로직 제거
2026-08-06 09:59:34 +09:00
Rinjae b3fc5c06bf webhook 수정 화면에서 이벤트 코드 출력 제거.
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-06 09:24:48 +09:00
Rinjae eb74a99a5a - PersonalDataEncryptConverter: isFakeMode 메서드 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- LegacyEncryptionMigration: 허용 IP 검증 로직 추가 - 스킵 내역 로그 강화
- 암호화 대상 컬럼 추가 및 DamoMode 구분 지원
2026-08-05 17:47:37 +09:00
Rinjae 38e5a7f13c API 카드 레이아웃 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 1행 4열 고정 및 반응형 flex-wrap 적용
- 카드 간격/정렬 방식 및 max-width 설정 추가
2026-08-05 15:42:48 +09:00
Rinjae 3b7c2e5a8f 푸터 "관련 사이트" 조회 및 렌더링 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- RelatedSite/RelatedSiteService 생성 및 PortalProperty 연계
- GlobalControllerAdvice에서 셀렉트 라벨/목록 주입
- footer.html 템플릿 및 관련 JS 수정
2026-08-05 15:23:59 +09:00
Rinjae 47099ec485 Merge branch 'feats/menu-control'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-05 14:21:22 +09:00
Rinjae 6dbf6af3de 서비스 소개 페이지 텍스트 간소화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 'DJBank API Portal 소개'로 제목 및 내용 정리
2026-08-05 14:19:58 +09:00
Rinjae 5585df9133 메뉴 기능 추가 - menu.yml GNB 정의 - 접근 인터셉터/뷰 주입/내부 API 구현 2026-08-05 14:19:33 +09:00
Rinjae 2c63fa1557 개발자포탈 미게시 GW 인터페이스 표시 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- hiddenApiCount 필드로 미게시 인터페이스 집계 노출
- API Pills 및 관련 CSS/JS 수정
- 영향 인터페이스 노출 로직 개선 및 VisibleApis 클래스 적용
2026-08-05 10:44:50 +09:00
Rinjae d71af34950 공지 연계 기능 추가 및 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 진행 중, 과거 장애/점검 카드에 공지 제목 및 본문 추가.
- 공지 본문 2열 레이아웃 및 세로 스크롤 스타일 적용.
- CSS 반응형 수정(768px → 1024px).
2026-08-05 09:33:22 +09:00
Rinjae fbc145d145 로고 워드마크 정렬 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모바일 로고 텍스트 및 링크 baseline 맞춤.
- CSS 수정 및 스타일 정렬 개선.
2026-08-04 19:27:54 +09:00
Rinjae f36b1a6478 Merge branch 'master' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-08-04 19:23:45 +09:00
Rinjae 8f03209f8a UI 문구 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 공지 상세 화면 "끝" → "종료"로 수정
- Webhook 가이드 "장애 시작" → "장애 발생"으로 변경
2026-08-04 16:20:11 +09:00
hong 8743592fde fix : footer 수정 2026-08-04 14:59:49 +09:00
hong a0685c8689 merge 충돌 해결 2026-08-04 14:53:33 +09:00
hong c0ae60f738 fix : 부분적으로 수정 2026-08-04 14:50:27 +09:00
Rinjae b45a7a162a - IncidentTimelineUI VO 추가 - 장애 타임라인 화면 구현 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PortalNoticeManService 장애 상태 타임라인 로직 추가
- 메인 CSS에 타임라인 스타일 추가
2026-08-04 13:10:31 +09:00
Rinjae 07fbe1ba54 장애·지연 구분 및 집계 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 지연 유형 로직 및 UI/스타일 업데이트
- JPQL 조회와 공개 조건 로직 전면 개정
2026-08-03 16:57:57 +09:00
Rinjae 4916bdf4af API 상태 지연 필터 및 UI/로직 개선:
- 장애·지연 구분 및 집계 로직 추가 대응
- 지연 유형 반영 색상/태그/레이블/UI 경로 업데이트
2026-08-03 15:33:21 +09:00
Rinjae 35621b5174 Merge branch 'master' into feats/api-status 2026-07-31 17:35:11 +09:00
Rinjae b97b0fcf7b "SBOM 생성 및 변환 추가 - CycloneDX -> Excel(xlsx) 지원 - Jenkins 단계에 SBOM 빌드 및 아티팩트 보관 연결"
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-07-31 17:31:45 +09:00
Rinjae 6cb98d36bb API Status 개선 및 조회 기간 설정 추가 2026-07-31 16:22:43 +09:00
Rinjae fd229fca43 API Status 페이지 기능 및 UI 업데이트:
- 상태 모니터링 시각 및 세부 정보 노출 관련 로직 추가
- 지연 필터 및 상대 시간 표기 기능 구현
- 점검/장애 필터 옵션 및 탐지 메타 정보 조회 로직 보완
2026-07-31 14:42:09 +09:00
Rinjae 5e8d08f1af API Status 페이지 포탈 미게시 API 안내 추가:
- 포탈 미게시 API 필터 안내 배너 HTML 및 JS 추가
- 조회 시 API 명 표시 및 API 옵션 외 필터 유지 로직 변경
2026-07-31 14:10:39 +09:00
Rinjae e7945233fe Merged from master 2026-07-31 13:41:25 +09:00
Rinjae 815e2bdd04 API Status 페이지 UI 및 주요 기능 추가:
- API Status 페이지 SCSS 스타일 및 반응형 대응 추가
- 진행 중 장애 DTO 및 영향 API DTO 구현
- 진행 중 장애 및 점검 사항 관련 JS 렌더링 로직 추가
2026-07-31 13:38:37 +09:00
Rinjae 0629e842a3 푸터 외부 링크 스타일 추가 및 SCSS 리팩토링
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- footer-link--external 스타일 추가로 외부 링크 시각화
- 불필요한 CSS 줄바꿈 및 공백 제거로 파일 최적화
2026-07-31 13:33:41 +09:00
Rinjae 6fa2167378 Swing 알림 체계 도입 - 기존 Q&A 관리자 알림 제거 - Swing Notifier 구현 2026-07-30 19:33:11 +09:00
Rinjae 84e873a08b Merge branch 'design' into feats/api-status 2026-07-30 17:42:29 +09:00
Rinjae 6c231c4189 API 해지 팝업 추가 및 사용자 오류 메시지 처리 개선
- API 해지 신청 팝업 UI 및 로직 구현
- 사용자 친화적 예외 처리 헬퍼(UserErrorMessageResolver) 추가
- Webhook 신청/수정, 글로벌 예외 처리에 사용자 메시지 연동
2026-07-30 16:22:45 +09:00
Rinjae e3b5a44270 Merge branch 'master' into feats/api-status 2026-07-30 13:44:50 +09:00
Rinjae 829630ae5c API 스펙 관리 문구 및 로직 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 게이트웨이 관련 문구 수정 및 그룹 설정 안내 추가
- 비 로그인 사용자 공개 관련 문구 수정
- description 필드 저장/복원 로직 추가
2026-07-30 11:24:05 +09:00
Rinjae 2a9694cd85 API 스펙 관리 문구 및 로직 개선
- 게이트웨이 관련 문구 수정 및 그룹 설정 안내 추가
- 비 로그인 사용자 공개 관련 문구 수정
- description 필드 저장/복원 로직 추가
2026-07-30 11:24:05 +09:00
hong 456b635a32 merge 충돌 해결 2026-07-30 10:07:00 +09:00
hong faaf9e8cd6 feat: 로그인 퍼블리싱 2026-07-30 10:01:38 +09:00
Rinjae 4749613947 - PortalOrg 초기화 필드 추가(orgDesc, ipWhitelist 등)
- ApiTesterFilter 디버깅 로직/유틸 추가(maskHeaders 등)
- jackson-core 취약점(CVSS 7.5) 수정 버전 업그레이드(2.18.6)
2026-07-29 20:20:43 +09:00
Rinjae 5125dab897 Merge branch 'design' of ssh://172.30.1.50:2222/djb-eapim/eapim-portal into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-07-29 19:32:08 +09:00
Rinjae 02eb81af67 클라이언트 생성/수정 흐름 및 관련 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 클라이언트 등록/수정 URL 구조 리팩토링 (myapikey → clients)
- 로그인 직후 클라이언트 미보유 사용자 대상 추가 안내 팝업
- 클라이언트 Secret 조회 및 관련 2FA 단계 로직 개선
2026-07-29 19:25:10 +09:00
Rinjae c02a5ff047 전역 토스트 알림 추가 및 UI/로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 전역 토스트 알림용 JS 및 SCSS 추가 (djbToast)
- 신규 토스트 연동 레이아웃(js 포함) 및 스타일 확장 대응
- 클라이언트 신청/수정 화면 개선, IP 등록 검증 로직 추가
2026-07-29 15:32:05 +09:00
hong 4181d114c1 style: SASS 빌드 결과 main.css 업데이트 2026-07-28 18:21:56 +09:00
hong daff07c006 merge후 충돌 해결 2026-07-28 18:18:23 +09:00
hong 1f51c9b21e feat: 로그인과 회원가입 퍼블리싱 2026-07-28 18:09:20 +09:00
Rinjae 929980c089 중복 로그인 확인 및 처리 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 동시 접속 확인 API(DuplicateLoginController) 및 서비스 구현
- 중복 세션 확인 후 로그인 확정/취소 처리 로직 추가
- 2FA 흐름과 연계된 프론트엔드 수정 및 UI 개선
2026-07-28 17:00:10 +09:00
Rinjae d4d5d66727 앱 수정 최종 반영(2FA) 로직 추가:
- 최종 저장 시 2FA 통과권 요구 로직 추가 및 중복 인증 방지
- Step2 2FA 팝업 및 프론트엔드 2FA 검증 로직 반영
- 불필요한 CallBack URL 필드 제거 및 UI 문구 수정
2026-07-28 16:38:44 +09:00
Rinjae d3e6249a41 앱 수정 최종 반영(2FA) 로직 추가:
- 최종 저장 시 2FA 통과권 요구 로직 추가 및 중복 인증 방지
- Step2 2FA 팝업 및 프론트엔드 2FA 검증 로직 반영
- 불필요한 CallBack URL 필드 제거 및 UI 문구 수정
2026-07-28 16:38:37 +09:00
Rinjae 68f8c07fc0 Merge branch 'master' into design 2026-07-28 16:07:51 +09:00
Rinjae 87369ce7f7 법인 로그인 차단 로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- ACTIVE 외 상태(휴면/탈퇴/준비) 로그인 제한 처리 추가
- 승인 미완료 예외 케이스 방어 로직 강화
2026-07-28 15:38:18 +09:00
Rinjae a2f6151a08 인증 관련 PTL_PROPERTY 및 로그인 차단 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 개발/테스트 환경 인증번호 노출 설정 AuthNoticeProperties 추가
- 삭제된 법인 소속 사용자 로그인 차단 처리 로직 반영
2026-07-28 15:31:21 +09:00
Rinjae b29882c0b2 이메일 인증 흐름 개선 및 테스트 환경 대응:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 가입 시 이메일 인증 여부 검증 로직 추가
- 테스트 환경에서 인증번호 UI 출력 및 관리 개선
- 리다이렉트 시 기본 모델 노출 방지 Bean 설정 추가
2026-07-28 15:13:55 +09:00
hong 80740a9543 merge 충돌 해결 2026-07-28 14:35:21 +09:00
hong cdd68f48f0 fix: 마이페이지 이용 통계 , 내정보 관리 , 비밀전호 변경 & 로그인 반응형 퍼블리싱 2026-07-28 14:30:03 +09:00
Rinjae d2bc61dc31 2FA 서비스 및 속성 추가 - 테스트 모듈 적용
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 비밀번호 변경 경로 리팩토링 및 엔드포인트 변경
- 테스트 경로 및 목(mock) 객체 추가
2026-07-28 12:41:02 +09:00
Rinjae 73deb7f02e WebLogic WebSocket 충돌 방지 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Tomcat WebSocket 모듈(tomcat-embed-websocket) 제외 추가
2026-07-28 11:27:58 +09:00
Rinjae fedd3e4c50 본인 인증 추가 기능 및 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Step-up 비밀번호 확인 페이지 구현
- 기존 비밀번호 확인 로직 제거 및 반영 직전 2FA 추가
- 보호 경로 완화 정책(PASSWORD/TWO_FACTOR) 및 경로 관리 확장
2026-07-28 11:04:24 +09:00
Rinjae 6c13b6bc63 design → master 병합: 2FA·Step-up 인증 및 디자인 반영본 통합 2026-07-28 10:09:14 +09:00
Rinjae b54fedc280 feats/security → design 병합: 2FA·Step-up 인증 통합
- SuccessHandler 충돌 해결: LoginFinalizer 리팩터 버전 채택
- LoginFinalizer 로그 마스킹 재적용(design 변경 보존)
- CSS 생성물 충돌은 SASS 재컴파일로 해소(.tfa- 포함)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 09:55:57 +09:00
Rinjae a4316545dc 2FA 팝업 UI 개선 및 기능 추가:
- 디자인 가이드 기반 레이아웃/스타일 재정의(SASS)
- 인증 흐름 개선: 타이머/프로그레스바/버튼 상태 업데이트(JS)
- 사용자 친화적 안내 메시지 및 입력 상태 검증 추가
2026-07-28 09:50:45 +09:00
Rinjae b5ffa69eba 2FA (추가 인증) 기능 추가:
- Step-up 인증 Interceptor 및 보호 경로 관리 로직 도입
- 2FA 팝업 모듈 및 스타일(SASS, JS) 추가
- 로그인 실패 사유 Enum 및 2FA 관련 처리 로직 공통화
2026-07-27 20:12:13 +09:00
Rinjae 2106142d93 - UserRoleHistoryService 추가: 역할 변경 감사 이력 서비스 구현
- `ROLE_API_KEY_REQUEST` → `ROLE_WEBHOOK` 변경: Webhook 관리 권한 분리 및 강화
- "이용자" → "개발자" 용어 통일: 템플릿, 컨트롤러 및 예외 메시지 수정
2026-07-27 20:11:09 +09:00
Rinjae 0f58f043a8 Merge remote-tracking branch 'origin/design' into design 2026-07-27 18:20:43 +09:00
Rinjae 61be6bf108 - API 선택 검증 로직 수정 - 선택 사항으로 변경
- '인증 키 관리' → '앱 관리' 용어 변경
- API 미선택 시 확인 팝업 로직 추가
2026-07-27 18:20:37 +09:00
hong 96d6dd3df1 충돌 해결 2026-07-27 17:44:23 +09:00
hong f9b53810d9 fix: 인증키 관리 & webhook 수정및 반응형 퍼블리싱 2026-07-27 17:32:33 +09:00
Rinjae 41be827b81 비밀번호 정책 및 변경 강제 적용 기능 추가:
- 비밀번호 규칙 클라이언트 검증 JS/SASS 추가
- 세션 기반 비밀번호 변경 강제 Interceptor 구현 (`PasswordChangeEnforcementInterceptor`)
- URL 리팩토링 및 엔드포인트 변경 (`/password/*`)
- 비밀번호 유효성 검사 공통화 (`PasswordRuleValidator`)
- Logback ERROR 전용 파일 로그 추가
2026-07-27 17:25:42 +09:00
Rinjae 78898ab5a8 Merge dfc859c (승인 요청 처리 개선) into design
- 승인정보 없는 요청 목록 노출 및 삭제 로직 (MyAppController, AppServiceFacade)
- '승인대기' → '승인정보 없음' 텍스트 (apiKeyList.html, design 뱃지 클래스 유지)
- 내부 결재 중 취소 불가 안내 스타일 (_apikey-detail.scss)
- 충돌 CSS는 design(ours) 채택 후 SASS 재컴파일
2026-07-27 16:14:53 +09:00
hong 19c17f4e91 Merge remote-tracking branch 'origin/master' into design 2026-07-27 14:40:12 +09:00
hong c2d84bb024 마이페이지 이용자 관리 , 인증키 관리 반응형으로 수정 2026-07-27 14:31:51 +09:00
hong 6434d48b05 Merge branch 'design' of http://172.30.1.50:3000/djb-eapim/eapim-portal into design 2026-07-24 17:41:43 +09:00
hong d5f0f3aab4 fix: FAQ , Q&A , 피드백 반응형 퍼블리싱 2026-07-24 17:41:39 +09:00
Rinjae dfc859c9cf 승인 요청 처리 개선 및 UI/로직 추가:
- 승인정보 없는 요청 목록 노출 및 삭제 로직 추가
- 내부 결재 진행 중 취소 불가 안내 메시지 및 스타일 추가
- '승인대기' -> '승인정보 없음' 텍스트 수정
2026-07-24 17:22:40 +09:00
Rinjae 24c669b0be 접근 권한 처리 및 권한 설정 개선:
- 미로그인 사용자 접근 시 로그인 페이지 리다이렉트 처리
- Webhook 관리 접근 권한 `ROLE_API_KEY_REQUEST`로 변경
- 오류 페이지 스타일 수정: `white-space: pre-line` 추가
2026-07-24 13:14:17 +09:00
hong 3eedc522e2 merge 충돌수정 2026-07-24 10:24:07 +09:00
hong a361cc3dec fix : open api , 공지사항 반응형 퍼블리싱 2026-07-24 10:19:18 +09:00
Rinjae 1ac9fa5407 "SwaggerUI - 실행 전 응답 UI 노출 방지 로직 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Execute 클릭 여부 확인 pendingExecute 플래그 도입
- 네트워크 에러/응답 처리 시 플래그 초기화 추가"
2026-07-24 09:44:28 +09:00
Rinjae 9981459691 "SwaggerUI - 실행 전 응답 UI 노출 방지 로직 추가
- Execute 클릭 여부 확인 pendingExecute 플래그 도입
- 네트워크 에러/응답 처리 시 플래그 초기화 추가"
2026-07-24 09:44:28 +09:00
Rinjae a2878f9cee Merge branch 'master' into design 2026-07-23 20:58:03 +09:00
Rinjae 1f10dda993 - API 감사 로거 추가 - APITesterAuditLogger 클래스 생성
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 회원가입 링크 수정 - href 속성 추가
- Testbed UI 스타일 추가 - CSS/SASS 코드 업데이트
2026-07-23 20:28:12 +09:00
Rinjae a6807a37c2 - API 감사 로거 추가 - APITesterAuditLogger 클래스 생성
- 회원가입 링크 수정 - href 속성 추가
- Testbed UI 스타일 추가 - CSS/SASS 코드 업데이트
2026-07-23 20:28:12 +09:00
Rinjae 6c10ae12c7 Merge branch 'master' into design 2026-07-23 20:26:55 +09:00
Rinjae 01c4a80182 API 테스트베드 개선 및 감사 로그 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Gateway 프로퍼티 mock 토큰 관련 항목 추가
- API 요청/응답 감사 로그 작성 로직 구현 및 로깅 설정 추가
- Swagger UI 전용 spec 제공 및 서버 주소 치환 로직 분리
2026-07-23 20:26:40 +09:00
Rinjae a5611cf775 - 서비스 가이드 컬럼 레이아웃 개선: 1열 배치로 변경
- 다국어 코드 블럭 간격 문제 수정 및 SASS 재컴파일
- 버튼 및 배지 hover 컬러 RGB % 표기로 변경
2026-07-23 18:12:12 +09:00
Rinjae c733e6b200 Merge branch 'master' into design
# Conflicts:
#	src/main/resources/templates/views/apps/service/oauth2-guide.html
2026-07-23 18:09:00 +09:00
Rinjae a2813f391c - OAuth2 가이드 scope 고정값 "api" 반영(master 이식)
- 인증 헤더 표기 X-AUTH-TOKEN 으로 수정
- 가이드 히어로/사전준비/엔드포인트 1열 배치 전환
- webhook 개발가이드 문구 정리, SASS 재컴파일

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:07:58 +09:00
Rinjae f221c20ece - 세션 타임아웃 10분 고정: 서버/DB 설정 통일 및 관리 단순화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSRF 토큰 조회/재발급 API 추가 - 익명 세션 유지 핑 로직 구현
- OAuth2 가이드 scope 고정값 "api"로 수정
2026-07-23 18:05:57 +09:00
Rinjae 4a3b5b43f3 불필요 주석 블록 처리:
- HTML 템플릿 주석 CSS 규격으로 통일
- guide.html 외 10개 파일 변경
- editor-content 클래스 관련 설명 추가
2026-07-22 15:51:43 +09:00
Rinjae 8de0d94063 API 관련 HTML 템플릿 파일 제거 및 상세 뷰 클래스 추가:
- api_lnb.html, head.html 템플릿 삭제
- API 상세 페이지에 editor-content 클래스 추가
2026-07-22 14:40:12 +09:00
Rinjae 57d4efb4eb Merge branch 'master' into design 2026-07-22 11:09:13 +09:00
Rinjae c865359819 사용자 이메일 마스킹 처리 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PortalConfigLog에 StringMaskingUtil 적용
- 로그인 사용자 아이디 마스킹 처리(Log 개선)
2026-07-22 09:39:09 +09:00
Rinjae 41391c8df0 - Webhook 신청 및 관리 UI 전면 개편 - 신규 가이드 반영
- Webhook 수정/삭제 기능 UX 개선 - 단계별 디자인 업데이트
- Webhook 등록/수정 CSS 및 JS 로직 최적화 및 재사용 코드 추가
2026-07-20 19:53:54 +09:00
Rinjae 9f4874fe44 Merge branch 'feats/api-status' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
#	src/main/resources/static/css/main.min.css
#	src/main/resources/static/css/main.min.css.map
#	src/main/resources/templates/views/apps/mypage/apiKeyModifyStep2.html
#	src/main/resources/templates/views/apps/mypage/apiKeyRegisterStep2.html
#	src/main/resources/templates/views/fragment/djbank/header_container.html
2026-07-20 19:13:38 +09:00
Rinjae 43d915342e 모니터링 코드 엔티티 제거 및 API 선택 모듈 추가:
- MonitoringCode 엔티티, 리포지토리, 복합키 클래스 삭제
- API 선택 공용 모듈 JS 및 HTML 파일 추가 (api_selector)
- 웹훅 개발 가이드 페이지 신규 추가 (webhook-dev-guide)
2026-07-20 19:10:05 +09:00
Rinjae ba00b80bfe Webhook 신청/관리 및 EventType 제공 기능 추가:
- Webhook 신청/수정 3단계 UI 및 관련 서비스 구현
- TSEAIRM28 공통코드 기반 EventType 조회 기능 추가
- 신규/수정 세션 관리 및 비밀번호 재인증 로직 포함
2026-07-20 17:36:17 +09:00
Rinjae 87763ba325 CSS 메인 파일 업데이트:
- "main.min.css"에서 디자인 스타일 및 애니메이션 수정 및 최적화.
2026-07-20 16:48:12 +09:00
Rinjae 5b7a4a108d design-merge 강제 병합 - 디자인 반영본 전체 통합
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:32:18 +09:00
hong 9b9bcb5be4 패딩 수정 2026-07-20 14:05:43 +09:00
Rinjae bdb249b74b Merge branch 'master' into feats/api-status 2026-07-20 10:57:55 +09:00
hong 93f7c210bc 11 2026-07-14 15:52:10 +09:00
hong bb79b55f08 fix: 회원가입 , oauth 반응형 수정 2026-07-14 14:54:53 +09:00
hong 23bd0c1207 fix : 메인페이지 반응형 수정 2026-07-14 10:35:14 +09:00
hong 28a9ff1a7f feat: 마이페이지 퍼블리싱 2026-07-14 10:34:31 +09:00
hong 045f354e5c Q&A 리스트 퍼블리싱 2026-07-09 10:45:20 +09:00
hong 4f577c8c14 Faq , q&a 상세 , 피드백 , 마이페이지 이용자 관리 퍼블리싱 2026-07-09 09:47:16 +09:00
hong cc4c1a6b53 feat : 오픈 API & 공지사항 퍼블리싱 2026-07-06 17:21:58 +09:00
hong 551a2da717 feat: 회원가입 안내 및 oauth 2 페이지 퍼블리싱 2026-07-03 16:41:24 +09:00
hong 1c57d348e6 디자인 시안 적용 및 애니메이션 개선 2026-07-02 17:52:59 +09:00
rinjae 67ec6d2103 Jenkinsfile.deploy 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 11:12:57 +09:00
rinjae b024f8e86f Jenkinsfile.test-build 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 11:09:38 +09:00
rinjae fd16300553 Jenkinsfile.test-build 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 10:51:00 +09:00
Rinjae 2c29c8a466 공지사항 목록 정렬 및 UI 개선
- 상단고정(fixYn) 우선 정렬 로직 추가
- 고정 뱃지 UI 템플릿 추가
2026-06-16 14:46:25 +09:00
Rinjae d2052d0e16 Q&A 이벤트 리팩토링
- InquiryCommentCreatedAdminEvent 클래스 제거
- build.gradle 리소스 처리 코드 주석 처리
2026-06-16 10:53:39 +09:00
Rinjae 9c4f3cfb04 공지사항 상세 - 장애/점검 정보 노출 추가
- PortalNoticeDTO 장애/점검 필드 확장
- Facade에서 djb_incident 데이터 결합
- 목록/상세에 유형 뱃지 시작 종료 영향 API 표시

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 09:11:28 +09:00
Rinjae 815c329e3c CI - 단위테스트 안정화 및 e2e 분리 워크플로 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- AccountControllerTest 현 컨트롤러 API 기준 재작성
- Selenium 4종·SignupFileTest @Tag("e2e") 부여
- BaseWebTest headless 옵션, e2e 워크플로 신설

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 19:50:58 +09:00
Rinjae 3dd9edc849 eapim-portal CI 테스트 워크플로 추가 2026-06-12 19:40:20 +09:00
Rinjae 6cff96f40a CI - systemd 유닛 동기화 및 리로드 단계 제거 2026-06-12 18:45:55 +09:00
Rinjae a97609d533 Gitea Generic Package 업로드 워크플로 추가
- WAR 파일 Gitea Generic Package 업로드 단계 추가
- 패키지 버전 및 업로드 실패 시 오류 처리 로직 포함
2026-06-12 18:43:18 +09:00
508 changed files with 44117 additions and 24004 deletions
+3
View File
@@ -83,6 +83,9 @@ file_upload/
# Ignore Gradle build output directory
build
# SonarScanner 작업 디렉터리
.scannerwork/
build_knbank.sh
+10 -3
View File
@@ -185,8 +185,9 @@ com.eactive.apim.portal/
설정: `config/PortalDatasourceConfiguration.java`
- 각 데이터베이스별 별도 EntityManager
- Atomikos JTA를 통한 분산 트랜잭션
- 트랜잭션 로그: `/Log/eapim/portal/`
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
- `transactionManager` (@Primary) → EMS
- `gatewayTransactionManager` → Gateway
### 설정 프로파일
@@ -359,7 +360,13 @@ return queryFactory.selectFrom(user)
적절한 propagation과 함께 `@Transactional` 사용:
- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성)
- 읽기 전용 작업: 최적화를 위해 `@Transactional(readOnly = true)`
- 다중 데이터베이스: Atomikos JTA가 자동으로 분산 트랜잭션 처리
- **다중 데이터베이스: 분산 트랜잭션(JTA/XA) 없음.** EMS·Gateway 각각 독립 로컬 트랜잭션이다.
- 무지정 `@Transactional` = EMS(`transactionManager`)
- **Gateway 엔티티(`com.eactive.apim.gateway.*`, `com.eactive.eai.data.entity.onl.*`)를 다루는 서비스는
`@Transactional("gatewayTransactionManager")` 를 명시**한다. 안 하면 게이트웨이 EntityManager 가
리포지토리 호출 단위로 닫혀 지연 로딩에서 `LazyInitializationException` 이 난다
(예: `ApiServiceService` — `ApiGroup.apiGroupApiList`).
- 두 DB 를 한 원자 단위로 묶어야 하는 작업은 만들지 않는다. 현재 Gateway 는 조회 전용이다.
### 에러 처리
+15
View File
@@ -80,6 +80,21 @@ pipeline {
}
}
}
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
+501
View File
@@ -0,0 +1,501 @@
// 보안 검사 통합 파이프라인 — OWASP Dependency-Check(SCA) + SonarQube(SAST).
//
// 두 검사는 보는 대상이 다르다. 하나만 돌리면 절반만 본다.
// SonarQube : 우리가 쓴 코드의 결함 (SQL Injection, XSS, 하드코딩 시크릿 …)
// Dependency-Check : 우리가 쓰는 라이브러리의 알려진 취약점 (CVE)
//
// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록만 고치면 된다)
// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME)
// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록 (없으면 PATH 탐색)
// 3) 노드에 Dependency-Check CLI 설치 (DC_HOME) — 설치 방법은 17-보안 가이드 문서 참고
// 4) NVD API Key 를 Secret text credential 로 등록 (기본 ID: nvd-api-key)
// 키가 없으면 NVD 갱신이 극심하게 느려진다(수 시간). 폐쇄망은 UPDATE_NVD=false 로 운영.
// 5) Quality Gate 대기를 쓰려면 SonarQube 에 <JENKINS_URL>/sonarqube-webhook/ 웹훅 등록
//
// JDK 분리: 빌드는 JDK 8, 스캐너/Dependency-Check 실행은 JDK 17.
// (SonarScanner CLI 5.x 는 JRE 17 필수, Dependency-Check 9.x 이상은 JRE 11 이상 필수)
pipeline {
agent { label 'djb-vm' }
triggers {
// 코드 변경 감지
pollSCM('H/30 * * * *')
// 코드가 그대로여도 새 CVE 는 계속 공개된다 → 야간 정기 재검사
cron('H 3 * * *')
}
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20'))
}
parameters {
booleanParam(
name: 'RUN_DEPENDENCY_CHECK',
defaultValue: true,
description: 'OWASP Dependency-Check(SCA) 실행'
)
booleanParam(
name: 'RUN_SONAR',
defaultValue: true,
description: 'SonarQube 정적 분석(SAST) 실행'
)
booleanParam(
name: 'RUN_TESTS',
defaultValue: false,
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
)
booleanParam(
name: 'UPDATE_NVD',
defaultValue: true,
description: 'NVD 취약점 DB 갱신. 폐쇄망(외부 인터넷 불가)이면 반드시 끈다 — 캐시된 DB 로만 검사한다.'
)
string(
name: 'FAIL_ON_CVSS',
defaultValue: '11',
description: '이 CVSS 점수 이상이면 검사 실패로 표시. 11 = 실패시키지 않음(리포트만). 예: 7 = High 이상'
)
booleanParam(
name: 'FAIL_BUILD_ON_FINDING',
defaultValue: false,
description: 'FAIL_ON_CVSS 위반 시 빌드를 FAILURE 로 만든다. 끄면 UNSTABLE 로만 표시.'
)
booleanParam(
name: 'SCAN_JS',
defaultValue: true,
description: '정적 JS 라이브러리(static/js, static/plugins)도 RetireJS 로 검사. 폐쇄망에서 오류나면 끈다.'
)
}
environment {
// --- Jenkins 설정 이름 (환경에 맞게 수정) ---
SONAR_ENV_NAME = 'SonarQube-Community'
SONAR_SCANNER_TOOL = ''
// NVD API Key 를 담은 Secret text credential ID. 없으면 키 없이 진행한다(느림).
NVD_API_KEY_CRED = 'nvd-api-key'
// --- 툴체인 ---
JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8)
JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner / dependency-check 실행용
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
// --- Dependency-Check ---
// CLI 설치 경로. 비어 있거나 없으면 Jenkins Tools 등록분 > PATH 순으로 탐색한다.
DC_HOME = '/apps/opts/dependency-check'
// NVD 캐시(H2 DB). 워크스페이스 밖에 두어야 빌드마다 1GB 이상을 다시 받지 않는다.
// 여러 Job 이 공유하므로 disableConcurrentBuilds 를 켠 채로 쓴다.
DC_DATA = '/apps/opts/dependency-check-data'
DC_TOOL_NAME = ''
DC_REPORT_DIR = 'build/reports/dependency-check'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
// eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다.
// 이 둘이 없으면 컴파일도 의존성 해석도 불가능하다.
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
rm -rf elink-portal-common
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps {
sh '''
set -eu
echo "--- build JDK ---"
java -version
gradle --version
echo "--- scanner/dependency-check JDK ---"
if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then
echo "JDK 17 이 없다: $JAVA_HOME_SCANNER"
echo "SonarScanner 는 JRE 17, Dependency-Check 는 JRE 11 이상을 요구한다."
exit 1
fi
"$JAVA_HOME_SCANNER/bin/java" -version
'''
}
}
// build/generated 잔여 산출물이 남으면 MapStruct Impl / QueryDSL Q클래스가
// 중복 생성되어 컴파일이 깨진다. clean 이 파일 잠금 등으로 실패하는 경우가 있어
// 생성 소스 디렉터리는 별도로 먼저 지운다.
stage('Compile') {
steps {
sh '''
set -eu
rm -rf build/generated build/classes
gradle clean classes testClasses --no-daemon
'''
}
}
stage('Test') {
when { expression { return params.RUN_TESTS } }
steps {
sh 'gradle test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
}
}
}
// 배포본에 실리는 것과 같은 목록(runtimeClasspath)만 모은다.
// 이게 없으면 Dependency-Check 가 스캔할 jar 가 없어 "취약점 0건" 이라는 거짓 안심을 준다.
stage('Export dependency jars') {
when { expression { return params.RUN_DEPENDENCY_CHECK } }
steps {
sh 'gradle -I ci/dependency-check-classpath.gradle exportDependencyJars --no-daemon'
sh '''
set -eu
COUNT=$(find build/dependency-check/libs -name '*.jar' -type f | wc -l | tr -d ' ')
echo "scan target jars: $COUNT"
if [ "$COUNT" -eq 0 ]; then
echo "수집된 jar 가 0개다. 의존성 해석이 실패했는지 확인할 것."
exit 1
fi
'''
}
}
stage('OWASP Dependency-Check') {
when { expression { return params.RUN_DEPENDENCY_CHECK } }
steps {
script {
// CLI 위치 결정: DC_HOME > Jenkins Tools 등록분 > PATH
def dcHome = null
if (env.DC_HOME?.trim() && fileExists("${env.DC_HOME}/bin/dependency-check.sh")) {
dcHome = env.DC_HOME.trim()
echo "Dependency-Check: ${dcHome} (DC_HOME)"
} else {
def candidates = env.DC_TOOL_NAME?.trim()
? [env.DC_TOOL_NAME.trim()]
: ['dependency-check', 'Dependency-Check', 'OWASP Dependency-Check', 'dependency-check-cli']
for (name in candidates) {
try {
dcHome = tool name: name,
type: 'org.jenkinsci.plugins.DependencyCheck.tools.DependencyCheckInstallation'
echo "Dependency-Check tool: '${name}' -> ${dcHome}"
break
} catch (ignored) {
// 등록되지 않은 이름은 건너뛴다
}
}
if (dcHome == null) {
echo 'Jenkins Tools 에 등록된 Dependency-Check 가 없다. PATH 의 dependency-check.sh 로 진행한다.'
}
}
// NVD API Key credential 이 있는지 먼저 확인한다.
// (없는 credential 로 withCredentials 를 감싸면 파이프라인 자체가 죽는다)
def hasNvdKey = true
try {
withCredentials([string(credentialsId: env.NVD_API_KEY_CRED, variable: 'NVD_PROBE')]) {
// 존재 확인만 한다
}
} catch (ignored) {
hasNvdKey = false
}
if (!hasNvdKey) {
echo "NVD API Key credential('${env.NVD_API_KEY_CRED}') 이 없다. 키 없이 진행한다."
echo 'NVD 갱신이 매우 느려진다(수 시간). https://nvd.nist.gov/developers/request-an-api-key 에서 발급 권장.'
}
def dcScript = '''
set -eu
# Dependency-Check 는 JRE 11 이상 필요 (빌드용 JDK 8 과 분리)
export JAVA_HOME="$JAVA_HOME_SCANNER"
export PATH="$JAVA_HOME/bin:$PATH"
mkdir -p "$DC_DATA" "$DC_REPORT_DIR"
if [ -n "${DC_RESOLVED_HOME:-}" ] && [ -x "$DC_RESOLVED_HOME/bin/dependency-check.sh" ]; then
DC="$DC_RESOLVED_HOME/bin/dependency-check.sh"
elif command -v dependency-check.sh >/dev/null 2>&1; then
DC="$(command -v dependency-check.sh)"
else
echo "dependency-check.sh 를 찾지 못했다."
echo "노드에 CLI 를 설치하고 DC_HOME 을 맞추거나, Manage Jenkins > Tools 에 등록할 것."
exit 1
fi
echo "dependency-check: $DC"
ARGS=""
# 폐쇄망/오프라인: 캐시된 NVD DB 로만 검사한다.
if [ "${UPDATE_NVD_FLAG}" != "true" ]; then
ARGS="$ARGS --noupdate"
if [ ! -d "$DC_DATA" ] || [ -z "$(ls -A "$DC_DATA" 2>/dev/null)" ]; then
echo "NVD 캐시가 비어 있는데 갱신이 꺼져 있다: $DC_DATA"
echo "인터넷 되는 곳에서 1회 적재한 data 디렉터리를 복사해 둘 것."
exit 1
fi
fi
if [ -n "${NVD_API_KEY:-}" ]; then
ARGS="$ARGS --nvdApiKey ${NVD_API_KEY}"
fi
# OSS Index 분석기는 외부 서비스(ossindex.sonatype.org)를 호출한다.
# 갱신을 끈 환경(=외부 통신 불가)에서는 같이 끈다.
if [ "${UPDATE_NVD_FLAG}" != "true" ]; then
ARGS="$ARGS --disableOssIndex"
fi
# 자바 프로젝트에 불필요한 분석기 — 실행 시간만 늘린다.
ARGS="$ARGS --disableAssembly --disableNodeAudit --disableNodeJS"
SCAN_ARGS="--scan build/dependency-check/libs"
if [ "${SCAN_JS_FLAG}" = "true" ]; then
# 번들된 JS 라이브러리(jQuery, summernote 등)의 알려진 취약점 — RetireJS
[ -d src/main/resources/static/js ] && SCAN_ARGS="$SCAN_ARGS --scan src/main/resources/static/js"
[ -d src/main/resources/static/plugins ] && SCAN_ARGS="$SCAN_ARGS --scan src/main/resources/static/plugins"
else
ARGS="$ARGS --disableRetireJS"
fi
SUPPRESSION=""
if [ -f ci/dependency-check-suppressions.xml ]; then
SUPPRESSION="--suppression ci/dependency-check-suppressions.xml"
fi
# 토큰/키가 콘솔에 남지 않도록 실행 명령 자체는 출력하지 않는다.
set +x
"$DC" \
--project "DJB eAPIM Portal" \
$SCAN_ARGS \
--out "$DC_REPORT_DIR" \
--format HTML --format XML --format JSON \
--data "$DC_DATA" \
--failOnCVSS "${FAIL_ON_CVSS_VALUE}" \
$SUPPRESSION \
$ARGS
'''
def dcEnv = [
"DC_RESOLVED_HOME=${dcHome ?: ''}",
"UPDATE_NVD_FLAG=${params.UPDATE_NVD}",
"SCAN_JS_FLAG=${params.SCAN_JS}",
"FAIL_ON_CVSS_VALUE=${params.FAIL_ON_CVSS}"
]
// 정책: 기본은 UNSTABLE 로만 표시하고 뒤의 Sonar 분석까지 마친다.
// FAIL_BUILD_ON_FINDING 을 켜면 임계치 위반이 빌드 실패가 된다.
def onFinding = params.FAIL_BUILD_ON_FINDING ? 'FAILURE' : 'UNSTABLE'
catchError(buildResult: onFinding, stageResult: 'FAILURE') {
withEnv(dcEnv) {
if (hasNvdKey) {
withCredentials([string(credentialsId: env.NVD_API_KEY_CRED, variable: 'NVD_API_KEY')]) {
sh dcScript
}
} else {
sh dcScript
}
}
}
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true,
artifacts: 'build/reports/dependency-check/dependency-check-report.*'
script {
// OWASP Dependency-Check 플러그인이 설치돼 있으면 추이 그래프/경고를 남긴다.
// 없어도 리포트는 위에서 아카이브되므로 실패로 보지 않는다.
try {
dependencyCheckPublisher pattern: 'build/reports/dependency-check/dependency-check-report.xml'
} catch (ignored) {
echo 'OWASP Dependency-Check 플러그인 미설치 — 아카이브된 HTML 리포트로 확인할 것.'
}
}
}
}
}
// sonar.java.libraries 용 클래스패스 덤프.
// 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다.
stage('Export analysis classpath') {
when { expression { return params.RUN_SONAR } }
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon'
}
}
}
stage('SonarQube analysis') {
when { expression { return params.RUN_SONAR } }
steps {
script {
// 스캐너 위치 결정: 지정 이름 > 흔한 등록 이름 후보 > PATH 의 sonar-scanner
def candidates = env.SONAR_SCANNER_TOOL?.trim()
? [env.SONAR_SCANNER_TOOL.trim()]
: ['SonarScanner', 'SonarQube Scanner', 'sonar-scanner', 'SonarScanner CLI', 'sonarqube-scanner']
def scannerHome = null
for (name in candidates) {
try {
scannerHome = tool name: name, type: 'hudson.plugins.sonar.SonarRunnerInstallation'
echo "SonarScanner tool: '${name}' -> ${scannerHome}"
break
} catch (ignored) {
// 등록되지 않은 이름은 건너뛴다
}
}
if (scannerHome == null) {
echo 'Jenkins Tools 에 등록된 SonarQube Scanner 를 찾지 못했다. PATH 의 sonar-scanner 로 진행한다.'
echo '(Manage Jenkins > Tools 에 등록한 뒤 environment 의 SONAR_SCANNER_TOOL 에 그 이름을 넣으면 확실하다.)'
}
withSonarQubeEnv(env.SONAR_ENV_NAME) {
withEnv(["SONAR_SCANNER_HOME=${scannerHome ?: ''}"]) {
sh '''
set -eu
# 스캐너는 JDK 17 로 실행한다 (빌드용 JDK 8 과 분리)
export JAVA_HOME="$JAVA_HOME_SCANNER"
export PATH="$JAVA_HOME/bin:$PATH"
# 토큰은 커맨드라인(-Dsonar.token)에 노출시키지 않고 환경변수로만 넘긴다.
if [ -z "${SONAR_TOKEN:-}" ] && [ -n "${SONAR_AUTH_TOKEN:-}" ]; then
export SONAR_TOKEN="$SONAR_AUTH_TOKEN"
fi
LIBS=""
if [ -f build/sonar/java-libraries.txt ]; then
LIBS=$(cat build/sonar/java-libraries.txt)
else
echo "WARN: build/sonar/java-libraries.txt 없음 - 타입 해석 정확도 저하"
fi
TEST_LIBS=""
if [ -f build/sonar/java-test-libraries.txt ]; then
TEST_LIBS=$(cat build/sonar/java-test-libraries.txt)
fi
JUNIT_ARG=""
if [ -d build/test-results/test ]; then
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
fi
# SonarQube 에 Dependency-Check 플러그인이 설치돼 있으면 CVE 결과도 함께 올린다.
# 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만).
DC_ARG=""
if [ -f "$DC_REPORT_DIR/dependency-check-report.json" ]; then
DC_ARG="-Dsonar.dependencyCheck.jsonReportPath=$DC_REPORT_DIR/dependency-check-report.json"
if [ -f "$DC_REPORT_DIR/dependency-check-report.html" ]; then
DC_ARG="$DC_ARG -Dsonar.dependencyCheck.htmlReportPath=$DC_REPORT_DIR/dependency-check-report.html"
fi
fi
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
elif command -v sonar-scanner >/dev/null 2>&1; then
SCANNER="$(command -v sonar-scanner)"
else
echo "sonar-scanner 실행 파일을 찾지 못했다."
echo "Manage Jenkins > Tools > SonarQube Scanner 에 등록하거나 노드 PATH 에 설치할 것."
exit 1
fi
echo "scanner: $SCANNER"
"$SCANNER" \
-Dsonar.projectVersion="${BUILD_NUMBER}" \
-Dsonar.java.libraries="$LIBS" \
-Dsonar.java.test.libraries="$TEST_LIBS" \
$JUNIT_ARG \
$DC_ARG
'''
}
}
}
}
}
// SonarQube 웹훅으로 게이트 결과를 받아 판정한다.
// 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다.
stage('Quality Gate') {
when { expression { return params.RUN_SONAR } }
steps {
script {
def qg = null
try {
timeout(time: 15, unit: 'MINUTES') {
qg = waitForQualityGate abortPipeline: false
}
} catch (err) {
unstable("Quality Gate 결과 대기 실패/타임아웃: ${err}")
return
}
if (qg == null) {
unstable('Quality Gate 결과를 받지 못했다. SonarQube 웹훅 설정을 확인할 것.')
} else if (qg.status != 'OK') {
unstable("Quality Gate ${qg.status}")
} else {
echo 'Quality Gate OK'
}
}
}
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: '.scannerwork/report-task.txt'
script {
def report = '.scannerwork/report-task.txt'
if (fileExists(report)) {
def url = readFile(report).readLines().find { it.startsWith('dashboardUrl=') }
if (url) {
echo "SonarQube: ${url.substring('dashboardUrl='.length())}"
}
}
if (fileExists('build/reports/dependency-check/dependency-check-report.html')) {
echo "Dependency-Check 리포트: ${env.BUILD_URL}artifact/build/reports/dependency-check/dependency-check-report.html"
}
}
}
}
}
+267
View File
@@ -0,0 +1,267 @@
// SonarQube 정적 보안 분석 전용 파이프라인.
//
// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록의 SONAR_* 만 고치면 된다)
// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME)
// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록
// (SONAR_SCANNER_TOOL 이 비어 있으면 흔한 이름을 탐색하고, 없으면 PATH 의 sonar-scanner 사용)
// 3) Quality Gate 대기를 쓰려면 SonarQube 서버에
// Administration > Configuration > Webhooks 에 <JENKINS_URL>/sonarqube-webhook/ 등록
// 4) Job 의 Git 설정에서 shallow clone 을 끄고 tags/history 를 받아와야 blame 기반 "새 코드" 판정이 정확하다.
//
// 빌드는 JDK 8, 스캐너 실행은 JDK 17 로 분리한다.
// (SonarScanner CLI 5.x / SonarQube 10.x 이상은 JRE 17 필수. JDK 8 로 실행하면 UnsupportedClassVersionError)
pipeline {
agent { label 'djb-vm' }
triggers {
pollSCM('H/30 * * * *')
}
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20'))
}
parameters {
booleanParam(
name: 'RUN_TESTS',
defaultValue: false,
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
)
}
environment {
// --- Jenkins 설정 이름 (환경에 맞게 수정) ---
// Manage Jenkins > System > SonarQube servers 의 Name
SONAR_ENV_NAME = 'SonarQube-Community'
// Manage Jenkins > Tools > SonarQube Scanner 에 등록한 이름.
// 비워두면 아래 후보 이름들을 순서대로 탐색하고, 그래도 없으면 PATH 의 sonar-scanner 를 쓴다.
SONAR_SCANNER_TOOL = ''
// --- 툴체인 ---
JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8)
JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner 실행용
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
// eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다.
// 이 둘이 없으면 컴파일 자체가 불가능하고, 컴파일 산출물이 없으면 Sonar Java 분석이 무력화된다.
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
rm -rf elink-portal-common
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps {
sh '''
set -eu
echo "--- build JDK ---"
java -version
gradle --version
echo "--- scanner JDK ---"
if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then
echo "JDK 17 이 없다: $JAVA_HOME_SCANNER"
echo "SonarScanner 는 JRE 17 이상을 요구한다. 노드에 설치하거나 JAVA_HOME_SCANNER 를 수정할 것."
exit 1
fi
"$JAVA_HOME_SCANNER/bin/java" -version
'''
}
}
// build/generated 잔여 산출물이 남으면 MapStruct Impl / QueryDSL Q클래스가
// 중복 생성되어 컴파일이 깨진다. clean 이 파일 잠금 등으로 실패하는 경우가 있어
// 생성 소스 디렉터리는 별도로 먼저 지운다.
stage('Compile') {
steps {
sh '''
set -eu
rm -rf build/generated build/classes
gradle clean classes testClasses --no-daemon
'''
}
}
stage('Test') {
when { expression { return params.RUN_TESTS } }
steps {
sh 'gradle test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
}
}
}
// sonar.java.libraries 용 클래스패스 덤프.
// 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다.
stage('Export analysis classpath') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon'
}
}
}
stage('SonarQube analysis') {
steps {
script {
// 스캐너 위치 결정: 지정 이름 > 흔한 등록 이름 후보 > PATH 의 sonar-scanner
def candidates = env.SONAR_SCANNER_TOOL?.trim()
? [env.SONAR_SCANNER_TOOL.trim()]
: ['SonarScanner', 'SonarQube Scanner', 'sonar-scanner', 'SonarScanner CLI', 'sonarqube-scanner']
def scannerHome = null
for (name in candidates) {
try {
scannerHome = tool name: name, type: 'hudson.plugins.sonar.SonarRunnerInstallation'
echo "SonarScanner tool: '${name}' -> ${scannerHome}"
break
} catch (ignored) {
// 등록되지 않은 이름은 건너뛴다
}
}
if (scannerHome == null) {
echo 'Jenkins Tools 에 등록된 SonarQube Scanner 를 찾지 못했다. PATH 의 sonar-scanner 로 진행한다.'
echo '(Manage Jenkins > Tools 에 등록한 뒤 environment 의 SONAR_SCANNER_TOOL 에 그 이름을 넣으면 확실하다.)'
}
withSonarQubeEnv(env.SONAR_ENV_NAME) {
withEnv(["SONAR_SCANNER_HOME=${scannerHome ?: ''}"]) {
sh '''
set -eu
# 스캐너는 JDK 17 로 실행한다 (빌드용 JDK 8 과 분리)
export JAVA_HOME="$JAVA_HOME_SCANNER"
export PATH="$JAVA_HOME/bin:$PATH"
# 토큰은 커맨드라인(-Dsonar.token)에 노출시키지 않고 환경변수로만 넘긴다.
# 플러그인 버전에 따라 SONAR_AUTH_TOKEN 만 주입되는 경우가 있다.
if [ -z "${SONAR_TOKEN:-}" ] && [ -n "${SONAR_AUTH_TOKEN:-}" ]; then
export SONAR_TOKEN="$SONAR_AUTH_TOKEN"
fi
LIBS=""
if [ -f build/sonar/java-libraries.txt ]; then
LIBS=$(cat build/sonar/java-libraries.txt)
else
echo "WARN: build/sonar/java-libraries.txt 없음 - 타입 해석 정확도 저하"
fi
TEST_LIBS=""
if [ -f build/sonar/java-test-libraries.txt ]; then
TEST_LIBS=$(cat build/sonar/java-test-libraries.txt)
fi
JUNIT_ARG=""
if [ -d build/test-results/test ]; then
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
fi
# Tools 에 등록된 스캐너가 없으면 PATH 에서 찾는다
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
elif command -v sonar-scanner >/dev/null 2>&1; then
SCANNER="$(command -v sonar-scanner)"
else
echo "sonar-scanner 실행 파일을 찾지 못했다."
echo "Manage Jenkins > Tools > SonarQube Scanner 에 등록하거나 노드 PATH 에 설치할 것."
exit 1
fi
echo "scanner: $SCANNER"
"$SCANNER" \
-Dsonar.projectVersion="${BUILD_NUMBER}" \
-Dsonar.java.libraries="$LIBS" \
-Dsonar.java.test.libraries="$TEST_LIBS" \
$JUNIT_ARG
'''
}
}
}
}
}
// SonarQube 웹훅으로 게이트 결과를 받아 판정한다.
// 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다.
stage('Quality Gate') {
steps {
script {
def qg = null
try {
timeout(time: 15, unit: 'MINUTES') {
qg = waitForQualityGate abortPipeline: false
}
} catch (err) {
unstable("Quality Gate 결과 대기 실패/타임아웃: ${err}")
return
}
if (qg == null) {
unstable('Quality Gate 결과를 받지 못했다. SonarQube 웹훅 설정을 확인할 것.')
} else if (qg.status != 'OK') {
unstable("Quality Gate ${qg.status}")
} else {
echo 'Quality Gate OK'
}
}
}
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: '.scannerwork/report-task.txt'
script {
def report = '.scannerwork/report-task.txt'
if (fileExists(report)) {
def url = readFile(report).readLines().find { it.startsWith('dashboardUrl=') }
if (url) {
echo "SonarQube: ${url.substring('dashboardUrl='.length())}"
}
}
}
}
}
}
+15
View File
@@ -101,5 +101,20 @@ pipeline {
}
}
}
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 빌드는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle sbomXlsx --no-daemon'
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
+1
View File
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
## 문서
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
+145 -13
View File
@@ -24,6 +24,24 @@ allprojects {
}
}
// 로컬 전용 라이브러리 — WebLogic 배포 산출물(war/bootWar)에서 제외한다.
// 정보보호 점검에서 Actuator/모니터링 라이브러리가 배포본에 실려 있으면 불필요하게 탐지되므로
// 로컬(bootRun/IDE)에서만 classpath 에 오르게 한다. 제외 로직은 아래 war/bootWar 블록.
//
// ※ Spring Boot 의 developmentOnly 를 쓰지 않는 이유
// developmentOnly 는 bootJar/bootWar 에서만 제외된다. 표준 war task 는 runtimeClasspath 를
// 그대로 쓰므로 산출물에 실린다 (실측: devtools 가 eapim-portal.war 에 포함되어 있었음).
// ※ 별도 configuration(localOnly)을 쓰지 않는 이유
// runtimeClasspath 에서 빠지면 IntelliJ 가 모듈 classpath 를 구성할 때도 빠져
// IDE 로 기동할 때 Actuator/SBA 가 동작하지 않는다.
def localOnlyLibPrefixes = [
'spring-boot-devtools',
'spring-boot-starter-actuator',
'spring-boot-actuator', // spring-boot-actuator, -autoconfigure 모두 매칭
'micrometer-', // actuator 전용(runtimeClasspath 상 다른 출처 없음 — 확인함)
'spring-boot-admin-',
]
dependencies {
annotationProcessor "org.projectlombok:lombok:1.18.28"
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0", "org.mapstruct:mapstruct-processor:1.5.5.Final"
@@ -42,14 +60,33 @@ dependencies {
// implementation project(':kjb-safedb')
implementation('org.springframework.boot:spring-boot-starter')
implementation 'org.springframework.boot:spring-boot-starter-jta-atomikos'
implementation('org.springframework.boot:spring-boot-starter-web')
implementation('org.springframework.boot:spring-boot-starter-validation')
// ↓ 로컬 전용. war/bootWar 산출물에서는 localOnlyLibPrefixes 로 제외된다.
// runtimeOnly 인 이유: compileClasspath 에서 빠지므로 자바 코드가 이 API 를 참조하면
// 컴파일 단계에서 막힌다. implementation 이면 참조가 컴파일에 통과해버리고,
// 배포본(actuator/SBA 제외됨)에서 NoClassDefFoundError 로 터진다.
// IntelliJ / bootRun 은 runtimeClasspath 기준이라 로컬 기동에는 정상 포함된다.
// 설정(application-local*.yml)으로만 사용한다.
runtimeOnly('org.springframework.boot:spring-boot-starter-actuator')
// Spring Boot Admin client. 2.7.16 = Spring Boot 2.7.x 대응 마지막 계열(Java 8 호환).
runtimeOnly('de.codecentric:spring-boot-admin-starter-client:2.7.16')
// 위 runtimeOnly 원칙의 유일한 예외. InterceptorsEndpointConfig 가 actuator API
// (@Endpoint / InfoContributor)를 참조해야 하므로 compileClasspath 에만 올린다.
// compileOnly 는 runtimeClasspath 에 포함되지 않으므로 war/bootWar 산출물에는 영향이 없다.
// 해당 클래스는 @ConditionalOnClass 로 actuator 부재 시 로드되지 않으니
// 배포본(actuator 제외)에서 NoClassDefFoundError 가 나지 않는다.
compileOnly('org.springframework.boot:spring-boot-actuator')
implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.0'
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
// Thymeleaf 3.1 코어가 #temporals 를 내장 제공 → java8time extras 를 함께 두면 표현식 객체가 중복 등록된다.
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
}
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation('org.springframework.boot:spring-boot-starter-cache')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
@@ -57,32 +94,48 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'org.springframework:spring-expression:5.3.30'
// spring-expression 개별 pin 제거: 아래 ext 의 spring-framework.version 이 전 모듈을 일괄 관리한다.
// 개별 pin 이 남아 있으면 다른 spring-* 모듈보다 낮은 버전으로 고정되어 버전이 어긋난다.
implementation group: 'xalan', name: 'xalan', version: '2.7.3'
implementation 'org.hibernate:hibernate-envers:5.6.15.Final'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.0.0'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.4.0' // 3.0.0 은 thymeleaf 3.0 전용
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
implementation 'com.github.ua-parser:uap-java:1.5.3'
// uap-java 제거: 소스 전체에 ua_parser 참조 0건이고, 1.5.3 은 snakeyaml 2.x 에서 삭제된
// SafeConstructor() no-arg 를 호출해 NoSuchMethodError 를 낸다. 되살릴 경우 1.6.1 이상.
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1'
implementation 'javax.servlet:javax.servlet-api:4.0.0'
implementation 'org.jasypt:jasypt:1.9.3'
implementation 'xerces:xercesImpl:2.12.2'
implementation 'org.apache.commons:commons-lang3:3.12.0'
// weblogic.xml prefer-application-packages 가 com.fasterxml.jackson.* 를 앱 우선으로 강제한다
// (oracle_common jackson-databind 2.17.0 vs 앱 2.18.10 split-package 방지, WLog 실사용 확인).
// Thymeleaf StandardJavaScriptSerializer 가 ObjectMapper.findAndRegisterModules() 로
// jackson-module-jaxb-annotations 를 옵션 모듈로 자동탐색하는데, 이 모듈은 WebLogic 번들에만
// 있고 앱엔 없어 ServiceConfigurationError(Provider not found) 발생 → 앱에도 명시 추가해
// jackson 전체를 자체 완결시킨다(jackson-bom 2.18.10 로 버전 통일).
implementation 'com.fasterxml.jackson.module:jackson-module-jaxb-annotations'
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
implementation 'org.apache.commons:commons-lang3:3.20.0'
implementation 'org.apache.commons:commons-collections4:4.4'
implementation 'commons-net:commons-net:3.9.0'
implementation('commons-beanutils:commons-beanutils:1.9.4') {
// CVE-2025-48734 (PropertyUtilsBean 이 enum 의 declaredClass 프로퍼티 노출 → ClassLoader 접근/RCE).
// 1.11.0 부터 SuppressPropertiesBeanIntrospector 가 기본 활성이라 declaredClass 접근이 차단된다.
// 이 앱의 호출부(PasswordMatchValidator / PasswordRuleValidator / AuthNumberValidator)는
// 어노테이션에 박힌 고정 프로퍼티명만 넘기므로 외부 입력 경로는 없지만 버전은 올려 둔다.
// 1.11.0 = Java 8 바이트코드(major 52), PropertyUtils.getProperty/getNestedProperty API 동일.
implementation('commons-beanutils:commons-beanutils:1.11.0') {
// exclude group: 'commons-collections', module: 'commons-collections'
}
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3'
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.3'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
// jackson 개별 pin 제거: 아래 ext 의 jackson-bom.version 이 전 모듈을 일괄 관리한다.
// 개별 pin 은 BOM 보다 우선하므로 남겨 두면 BOM 만 올렸을 때 core/annotations/databind 가
// 옛 버전에 고정돼 버전이 어긋난다(실제로 그런 상태였다).
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
@@ -92,7 +145,10 @@ dependencies {
implementation 'net.bytebuddy:byte-buddy:1.14.5'
// Commons FileUpload (WAS 독립적인 multipart 처리)
implementation 'commons-fileupload:commons-fileupload:1.5'
// CVE-2025-48976 (멀티파트 파트 헤더 크기 제한 부재 → DoS). 1.6.0 에서 partHeaderSizeMax 도입.
// 주의: 1.6 부터 파트 헤더 총량 기본 상한이 10240 → 512 바이트로 줄었다(DEFAULT_PART_HEADER_SIZE_MAX).
// 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 길어질 수 있어 실측으로 여유를 확인했다.
implementation 'commons-fileupload:commons-fileupload:1.6.0'
implementation 'commons-io:commons-io:2.15.1'
@@ -110,6 +166,49 @@ ext {
springMavenArtifactVersion = '5.3.30'
encoding = 'UTF-8'
profile = 'local'
// 내장 Tomcat 버전 상향 (Spring Boot 2.7.18 기본값 9.0.83 → 9.0.120).
// Boot BOM 의 tomcat.version 프로퍼티를 덮어써서 tomcat-embed-core/-el/-websocket 이 함께 올라간다.
// 9.0.x 계열 유지 = Servlet 4.0 / javax.* 네임스페이스 그대로, JDK8 호환.
set('tomcat.version', '9.0.120')
// CVE-2022-1471 (snakeyaml Constructor 임의 타입 역직렬화 → RCE). Boot 2.7.18 BOM 기본값 1.30 → 2.6.
// 앱/프레임워크 실경로는 이미 SafeConstructor 계열(OriginTrackedYamlLoader, spring-beans
// FilteringConstructor, swagger-parser DeserializationUtils)이라 익스플로잇 경로는 없었으나
// SCA 는 버전으로 판정하므로 2.x 로 올린다. snakeyaml 2.6 = Java 8 바이트코드(major 52).
// 2.6 인 이유: swagger-core 2.2.52 / swagger-parser-v3 2.1.45 가 요구하는 버전이라 강등이 없다.
// 부수 효과: swagger-parser 가 호출하는 LoaderOptions.setCodePointLimit(1.32+ API) 도 해소.
set('snakeyaml.version', '2.6')
// jackson 전 모듈 버전 통일(Boot 2.7.18 BOM 기본 2.13.5). 2.18.x 는 JDK8 호환 라인이다.
// 이유 3가지
// 1) snakeyaml 2.x 는 ParserImpl(StreamReader) 를 제거했고 jackson-dataformat-yaml 은
// 2.15+ 부터 ParserImpl(StreamReader, LoaderOptions) 를 쓴다 — 위 snakeyaml 상향의 전제.
// 2) WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정.
// 3) jackson-databind PolymorphicTypeValidator 우회(제네릭 타입 인자 미검증) — 2.18.8 에서 수정.
// 이 앱은 다형성 역직렬화(activateDefaultTyping/@JsonTypeInfo)를 쓰지 않아 노출 경로는 없다.
// 2.18.x 마지막 패치를 쓴다.
set('jackson-bom.version', '2.18.10')
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
// org.thymeleaf.spring5 + javax.servlet 그대로다.
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
set('thymeleaf.version', '3.1.4.RELEASE')
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
// 5.3.32~5.3.39 구간 CVE 정리용. 5.3.x 는 OSS EOL 이라 이 위로는 상용(Enterprise) 빌드뿐이다.
// 남는 5.3.x 지적(CVE-2026-41855 JMS Jackson 역직렬화, CVE-2016-1000027 HttpInvoker)은
// 5.3 계열에 수정본이 없고 앱이 JMS/HttpInvoker 를 쓰지 않으므로 억제 항목으로 따로 관리한다.
set('spring-framework.version', '5.3.39')
// Spring Security 5.7.x OSS 마지막 릴리스(Boot 2.7.18 BOM 기본 5.7.11).
// CVE-2026-22732(응답 커밋 후 보안 헤더 미기록)는 수정본이 5.7.22/5.8.24 = Enterprise 전용이라
// OSS 로는 올릴 수 없다. 우회책(HeaderWriterFilter.shouldWriteHeadersEagerly=true)은 동작 변경이라 분리 검토.
set('spring-security.version', '5.7.14')
}
@@ -128,7 +227,16 @@ bootRun {
// jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005'
sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조)
args = ["--spring.profiles.active=dev"]
// 로컬 기동 기본 프로파일.
// dev/stage/prod 는 리버스프록시(OHS) 뒤 WAR 배포용이라 server.forward-headers-strategy=framework 가
// 걸려 있다. framework 는 신뢰 프록시 목록 없이 X-Forwarded-* 를 그대로 신뢰하므로 앞단이 없는
// 로컬 기동에는 쓰지 않는다(로컬은 공통 기본값 native = Tomcat RemoteIpValve, 사설대역만 신뢰).
// 다른 프로파일로 띄우려면: gradle bootRun -PbootProfile=dev
// ('profile' 이 아니라 'bootProfile' 인 이유: 위 ext 블록이 profile='local' 을 이미 점유하고 있어
// findProperty('profile') 은 -P 지정 여부와 무관하게 항상 'local' 을 돌려준다.)
def bootRunProfile = (project.findProperty('bootProfile') ?: 'local_rinjaemac').toString()
args = ["--spring.profiles.active=" + bootRunProfile]
}
@@ -145,6 +253,14 @@ sourceSets {
configurations {
annotationProcessor
// WebLogic 배포 시 Tyrus WebSocket 필터(weblogic.websocket.tyrus.TyrusServletFilter)와
// 충돌 방지: WAR 에 번들된 Tomcat WsSci 가 javax.websocket.server.ServerContainer 속성을
// WsServerContainer 로 등록 → WebLogic Tyrus 필터가 TyrusServerContainer 로 캐스팅하다 실패.
// 앱은 WebSocket 미사용이므로 Tomcat WebSocket 모듈 제외.
all {
exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-websocket'
}
}
compileJava {
@@ -172,9 +288,21 @@ test {
enabled = true
}
// 로컬 전용 설정 파일. 배포 산출물(WAR)에 실리면 Actuator/SBA 설정이 그대로 노출되어
// 정보보호 점검에 불필요하게 걸린다. processResources 는 건드리지 않는다
// (bootRun 이 build/resources/main 을 그대로 쓰므로 로컬 기동이 깨진다).
def localOnlyResources = ['**/application-local*.yml']
// 배포 산출물에서 로컬 전용 라이브러리를 걸러낸다. localOnlyLibPrefixes 는 파일 상단 정의.
def excludeLocalOnlyLibs = { org.gradle.api.file.FileCollection cp ->
cp.filter { f -> !localOnlyLibPrefixes.any { p -> f.name.startsWith(p) } }
}
bootWar {
archiveFileName = "eapim-portal-boot.war"
mainClass = 'com.eactive.apim.portal.PortalApplication'
rootSpec.exclude(localOnlyResources)
classpath = excludeLocalOnlyLibs(classpath)
}
war {
@@ -183,6 +311,8 @@ war {
from('src/main/resources/jeus-web-dd.xml') { into 'WEB-INF' }
from('src/main/resources/weblogic.xml') { into 'WEB-INF' }
rootSpec.exclude(localOnlyResources)
classpath = excludeLocalOnlyLibs(classpath)
}
task printSourceSets {
@@ -194,4 +324,6 @@ task printSourceSets {
println " Output dir : ${srcSet.output.classesDirs.asPath}"
}
}
}
}
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
+58
View File
@@ -0,0 +1,58 @@
// OWASP Dependency-Check 가 스캔할 서드파티 jar 를 한 디렉터리로 모은다.
//
// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다.
// gradle -I ci/dependency-check-classpath.gradle exportDependencyJars --no-daemon
//
// 출력:
// build/dependency-check/libs/*.jar runtimeClasspath 의 외부 의존 jar (원본 파일명 유지)
// build/dependency-check/jars.txt 수집 목록(경로 1줄씩) — 진단용
//
// 왜 디렉터리로 모으는가
// Dependency-Check CLI 는 --scan <경로> 로 파일/디렉터리를 받는다. Gradle 캐시를 통째로 스캔하면
// 이 프로젝트가 쓰지 않는 버전까지 잡히고, 프로젝트 디렉터리만 스캔하면 의존 jar 가 아예 안 잡힌다.
// 실제 배포본에 실리는 것과 같은 목록(runtimeClasspath)만 모아야 결과가 배포 산출물과 일치한다.
//
// 파일명을 유지해야 하는 이유
// Dependency-Check 는 jar 안의 POM/MANIFEST 외에 파일명에서도 CPE(제품/버전)를 추론한다.
// 이름을 바꾸면 탐지 정확도가 떨어진다.
rootProject { project ->
project.plugins.withId('java') {
project.tasks.register('exportDependencyJars') {
group = 'verification'
description = 'Dependency-Check 스캔용 런타임 의존 jar 를 build/dependency-check/libs 에 모은다'
doLast {
def outRoot = new File(project.layout.buildDirectory.get().asFile, 'dependency-check')
def outDir = new File(outRoot, 'libs')
project.delete(outDir)
outDir.mkdirs()
// 컴포지트/서브프로젝트의 build 디렉터리 산출물(우리가 만든 jar)은 제외한다.
// 자체 코드는 SonarQube 가 보는 영역이고, SCA 대상은 외부 라이브러리다.
def buildDirs = project.allprojects.collect {
it.layout.buildDirectory.get().asFile.absolutePath
}
def isOwnArtifact = { File f ->
buildDirs.any { f.absolutePath.startsWith(it + File.separator) }
}
def jars = project.configurations.runtimeClasspath.files
.findAll { it.isFile() && it.name.endsWith('.jar') && !isOwnArtifact(it) }
.unique()
.sort { it.name }
project.copy {
from jars
into outDir
}
new File(outRoot, 'jars.txt').text =
jars.collect { it.absolutePath }.join(System.lineSeparator()) + System.lineSeparator()
def totalMb = (jars.sum { it.length() } ?: 0L) / (1024 * 1024)
logger.lifecycle("dependency-check scan target: ${jars.size()} jars, ${totalMb as int} MB -> ${outDir}")
}
}
}
}
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
OWASP Dependency-Check 오탐(false positive) 억제 목록.
규칙
1. 억제는 "오탐"에만 쓴다. 진짜 취약점을 조용히 숨기는 용도로 쓰지 않는다.
실제 취약하지만 당장 못 올리는 경우는 억제 대신 만료일(until)을 넣어 재검토를 강제한다.
2. 항목마다 <notes> 에 판단 근거와 판단자/일자를 남긴다. 근거 없는 억제는 리뷰에서 거절한다.
3. 범위를 좁게 잡는다. cve 단건 + 특정 파일(sha1/packageUrl)이 기본이고,
cpe 나 정규식 filePath 로 넓게 억제하지 않는다.
작성법
Dependency-Check HTML 리포트의 각 취약점 옆 "Suppress" 버튼을 누르면 해당 항목의
<suppress> 블록이 그대로 생성된다. 그것을 이 파일에 붙여넣고 <notes> 만 채우면 된다.
참고: https://dependency-check.github.io/DependencyCheck/general/suppression.html
-->
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
<!-- 예시 (실제 억제 시 주석을 풀고 값 교체)
<suppress until="2026-12-31Z">
<notes><![CDATA[
오탐 근거: 해당 CVE 는 X 기능을 사용할 때만 성립하는데 이 앱은 해당 API 를 호출하지 않음.
확인: 홍길동 / 2026-08-13 / 호출부 grep 결과 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.example/example-lib@.*$</packageUrl>
<cve>CVE-2026-00000</cve>
</suppress>
-->
<!-- ================================================================================
Spring 계열 Critical 5건 (2026-08-18 리포트 기준).
공통 배경: Spring Framework 5.3.x / Spring Boot 2.7.x / Spring Security 5.7.x 는 모두 OSS EOL 이라
남은 수정본이 상용(Enterprise/Tanzu)뿐이다. JDK 8 유지 제약상 Boot 3 + Spring 6 이관 전까지는
버전 상향으로 못 없앤다. 아래는 "코드에 트리거 경로가 없음"을 근거로 한 억제이며 만료일을 둔다.
버전 범위를 5.3.x / 2.7.x / 5.7.x 로 고정해 두었으므로, 이관 후에는 억제가 자동으로 풀린다.
재검토 시 확인할 것: (1) 각 근거 grep 이 여전히 0건인지 (2) OSS 수정본이 나왔는지.
================================================================================ -->
<!-- 1. Spring Boot: Cassandra SSL 호스트명 미검증 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-40974 는 Spring Boot 의 Cassandra SSL 자동설정이 SSL 번들의 호스트명 검증 설정을
드라이버에 전달하지 않는 문제다. 이 앱은 Cassandra 를 쓰지 않는다
(runtimeClasspath 에 cassandra/datastax 계열 jar 0건, spring-boot-starter-data-cassandra 미선언).
수정본 2.7.33 은 상용(Enterprise) 릴리스라 OSS 로는 올릴 수 없다.
devtools/actuator/spring-boot-admin jar 는 스캔 대상(runtimeClasspath)에는 있으나
배포 산출물에서는 build.gradle 의 localOnlyLibPrefixes 로 제외되어 WAR 에 실리지 않는다.
확인: Rinjae / 2026-08-18 / grep -ri cassandra 0건, WAR 내 devtools/actuator jar 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
<cve>CVE-2026-40974</cve>
</suppress>
<!-- 2. Thymeleaf SSTI 2건: starter jar 이름 기준 오탐 -->
<suppress>
<notes><![CDATA[
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
thymeleaf.version=3.1.4.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.4.RELEASE.jar 다.
Dependency-Check 가 spring-boot-starter-thymeleaf-2.7.18.jar(의존만 선언한 빈 starter)에
thymeleaf CPE 를 잘못 매칭한 결과다. thymeleaf 본체 jar 에 대한 탐지는 억제하지 않는다.
확인: Rinjae / 2026-08-18 / WAR 내 thymeleaf-3.1.4.RELEASE.jar, thymeleaf-spring5-3.1.4.RELEASE.jar.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$</packageUrl>
<cve>CVE-2026-40477</cve>
<cve>CVE-2026-40478</cve>
</suppress>
<!-- 3. Spring Framework: JMS Jackson 역직렬화 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-41855 는 org.springframework.jms.support.converter.MappingJackson2MessageConverter /
JacksonJsonMessageConverter 가 임의 클래스 인스턴스화를 허용하는 문제다(신뢰할 수 없는 JMS 환경 전제).
이 앱은 JMS 를 쓰지 않는다: runtimeClasspath 에 spring-jms 0건, 소스에 javax.jms /
JmsTemplate / MappingJackson2MessageConverter 참조 0건.
5.3.x 는 OSS 수정본이 없다(6.2.19 / 7.0.8 에서만 수정). 이미 5.3.x OSS 마지막인 5.3.39 로 올려 둔 상태다.
확인: Rinjae / 2026-08-18 / grep -rn "javax.jms|JmsTemplate|MappingJackson2MessageConverter" src 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-.*@5\.3\..*$</packageUrl>
<cve>CVE-2026-41855</cve>
</suppress>
<!-- 4. spring-web: HttpInvoker 역직렬화 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2016-1000027 은 HttpInvokerServiceExporter 를 노출했을 때만 성립한다. 해당 클래스는
Spring 6.0 에서 제거됐고 5.3.x 에는 수정본이 없다(= 5.3.x 를 쓰는 한 계속 탐지된다).
이 앱은 HttpInvoker 계열을 쓰지 않는다: 소스에 HttpInvoker 참조 0건이고 외부 호출은
RestTemplate / HttpClient 기반이다.
근본 해결은 Spring 6(Boot 3, JDK 17) 이관. 이관 전까지 억제한다.
확인: Rinjae / 2026-08-18 / grep -rn HttpInvoker src 0건.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-web@5\.3\..*$</packageUrl>
<cve>CVE-2016-1000027</cve>
</suppress>
<!-- 5. Spring Security: 응답 커밋 시 보안 헤더 미기록 -->
<suppress until="2027-02-28Z">
<notes><![CDATA[
CVE-2026-22732 는 응답이 커밋된 뒤 Spring Security 가 보안 헤더를 기록하지 못하는 문제다.
트리거는 Content-Length 를 setHeader / setIntHeader / addIntHeader 로 지정하는 경로다
(OnCommittedResponseWrapper 는 setContentLength / setContentLengthLong / addHeader 만 추적한다).
(1) 이 코드베이스에는 트리거가 없다: portal / elink-portal-common / elink-online-core-jpa 전체에
setIntHeader, addIntHeader, setHeader("Content-Length") 0건. 파일 다운로드는
response.setContentLength(int) 를 쓰며 실측상 보안 헤더가 정상 기록된다.
(2) 그럼에도 안전망으로 PortalConfigSecurity 에서
HeaderWriterFilter.setShouldWriteHeadersEagerly(true) 를 적용해(spring.io 권고 우회책)
요청 시작 시점에 헤더를 기록하도록 했다.
수정본 5.7.22 / 5.8.24 는 Enterprise 전용이라 OSS 로는 올릴 수 없다. 이미 5.7.x OSS 마지막인
5.7.14 로 올려 둔 상태다.
확인: Rinjae / 2026-08-18 / 동일 스택(Boot 2.7.18 + Security 5.7.14 + Tomcat 9.0.120) 프로브 앱 실측 —
우회책 미적용 시 setHeader/setIntHeader/addIntHeader 경로에서 헤더 누락 재현, 적용 후 정상 기록.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework\.security/spring-security-.*@5\.7\..*$</packageUrl>
<cve>CVE-2026-22732</cve>
</suppress>
</suppressions>
+41
View File
@@ -0,0 +1,41 @@
// SonarScanner 의 sonar.java.libraries / sonar.java.test.libraries 로 넘길
// 컴파일 클래스패스를 파일로 덤프한다.
//
// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다.
// gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon
//
// 출력(콤마 구분, 절대경로):
// build/sonar/java-libraries.txt main 컴파일 클래스패스
// build/sonar/java-test-libraries.txt main + test 컴파일 클래스패스
//
// 이 파일이 없으면 Sonar Java 분석기가 타입을 해석하지 못해
// 보안 룰(SQL Injection, XSS 등) 상당수가 침묵한다. 선택 사항이 아니다.
rootProject { project ->
project.plugins.withId('java') {
project.tasks.register('exportSonarClasspath') {
group = 'verification'
description = 'SonarScanner 용 컴파일 클래스패스를 build/sonar 에 덤프한다'
doLast {
def outDir = new File(project.layout.buildDirectory.get().asFile, 'sonar')
outDir.mkdirs()
def toLine = { files ->
files.findAll { it.exists() }
.collect { it.absolutePath }
.unique()
.join(',')
}
def mainCp = project.sourceSets.main.compileClasspath.files
def testCp = project.sourceSets.test.compileClasspath.files
new File(outDir, 'java-libraries.txt').text = toLine(mainCp)
new File(outDir, 'java-test-libraries.txt').text = toLine(mainCp + testCp)
logger.lifecycle("sonar classpath dumped: ${outDir}")
}
}
}
}
@@ -0,0 +1,17 @@
<!--
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h1/h2/h3/p/ul/li/strong)만 사용.
admin Summernote 코드뷰에 그대로 붙여넣으면 됨. class/style 속성 없음 -> 붙여넣기(paste-cleaner)든
코드뷰 직접 입력이든 태그 정리 과정에서 잘려나갈 것이 없다.
-->
<h1>개인정보수집동의서</h1>
<p><strong>(주)제주은행 귀중</strong></p>
<p>귀 행과의 Open API Market 회원가입 거래와 관련하여 귀 행이 본인의 개인정보를 수집·이용하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 본인의 동의가 필요합니다.</p>
<h2>개인정보</h2>
<ul>
<li><strong>일반 개인정보:</strong> 성명, 직장명, 부서명, 연락처(휴대폰 번호, 직장번호), 이메일주소, 아이디, 비밀번호</li>
</ul>
<p>□ 동의하지 않음  □ 동의함</p>
<p>※ 본 동의 이후 동일한 목적 또는 이용범위 내에서 개인정보의 정확성, 최신성을 유지하기 위해 제공하는 경우에는 별도의 추가 동의가 필요하지 않습니다.</p>
<p>※ 은행의 고의 또는 과실 등 귀책사유로 인한 개인정보 유출로 고객님에게 발생한 손해에 대해 관계 법령 등에 따라 보상받으실 수 있습니다.</p>
<h3>위 개인정보 수집·이용에 동의하십니까?</h3>
<p>제정일자: 2026. 99. 99. 개정</p>
@@ -0,0 +1,19 @@
개인정보수집동의서
(주)제주은행 귀중
귀 행과의 Open API Market 회원가입 거래와 관련하여 귀 행이 본인의 개인정보를 수집·이용하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 본인의 동의가 필요합니다.
개인정보
ㄴ 일반 개인정보
성명, 직장명, 부서명, 연락처(휴대폰 번호, 직장번호), 이메일주소, 아이디, 비밀번호
□ 동의하지 않음 □ 동의함
※ 본 동의 이후 동일한 목적 또는 이용범위 내에서 개인정보의 정확성, 최신성을 유지하기 위해 제공하는 경우에는 별도의 추가 동의가 필요하지 않습니다.
※ 은행의 고의 또는 과실 등 귀책사유로 인한 개인정보 유출로 고객님에게 발생한 손해에 대해 관계 법령 등에 따라 보상받으실 수 있습니다.
위 개인정보 수집·이용에 동의하십니까?
제정일자: 2026. 99. 99. 개정
+162
View File
@@ -0,0 +1,162 @@
<!--
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h2/h3/p)만 사용.
admin Summernote 코드뷰에 그대로 붙여넣으면 됨(붙여넣기 시 badTags 정리 대상 없음).
약관탭(.content-body.editor-content), 회원가입 동의창, admin .note-editable.editor-content 3곳 모두
이 파일 안에서는 정의하지 않는 h2/h3/p 스타일은 공용 editor-content.css 가 담당한다.
-->
<h2>제1장 총칙</h2>
<h3>제1조 (목적)</h3>
<p>본 약관은 (주)제주은행(이하 “은행”)이 제공하는 Open API 서비스를 이용함에 있어 “은행”과 Open API 서비스를 이용하는 자(이하 “이용기관”)간의 권리, 의무 및 책임 사항, 이용조건 및 절차 등 기본적인 사항을 명확히 규정함을 목적으로 합니다.</p>
<h3>제2조 (용어의 정의)</h3>
<p>&nbsp;&nbsp;① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자</p>
<h3>제3조 (서비스의 내용)</h3>
<p>&nbsp;&nbsp;① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)</p>
<p>&nbsp;&nbsp;② 거래성 API 서비스(환전, 이체 등)</p>
<p>&nbsp;&nbsp;③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”</p>
<h3>제4조 (약관의 효력 및 변경)</h3>
<p>&nbsp;&nbsp;① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.</p>
<p>&nbsp;&nbsp;② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.</p>
<h2>제2장 서비스 이용계약</h2>
<h3>제5조 (서비스 이용신청)</h3>
<p>&nbsp;&nbsp;① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.</p>
<p>&nbsp;&nbsp;② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.</p>
<h3>제6조 (서비스 이용신청 승인 및 거절)</h3>
<p>&nbsp;&nbsp;① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.</p>
<p>&nbsp;&nbsp;② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. “이용신청기관”의 사업모델에 위법성이 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;6. “이용신청기관”의 재무안정성이 위험한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우</p>
<p>&nbsp;&nbsp;③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.</p>
<h2>제3장 서비스의 이용</h2>
<h3>제7조 (서비스 이용개시)</h3>
<p>“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.</p>
<h3>제8조 (서비스 이용시간)</h3>
<p>&nbsp;&nbsp;① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 은행의 시스템을 점검하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우</p>
<p>&nbsp;&nbsp;② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.</p>
<h3>제9조 (서비스 변경 및 중단)</h3>
<p>&nbsp;&nbsp;① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.</p>
<p>&nbsp;&nbsp;② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우</p>
<p>&nbsp;&nbsp;③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
<p>&nbsp;&nbsp;④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
<h3>제10조 (서비스 이용 제한)</h3>
<p>&nbsp;&nbsp;① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 감독당국으로부터 중지 권고가 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. “이용기관”이 휴업 또는 폐업을 하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우</p>
<p>&nbsp;&nbsp;② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.</p>
<h3>제11조 (서비스 이용 해지)</h3>
<p>&nbsp;&nbsp;① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 감독당국으로부터 해지권고가 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 최근 12개월간 “시스템” 이용 실적이 없는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우</p>
<p>&nbsp;&nbsp;② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.</p>
<p>&nbsp;&nbsp;③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.</p>
<h2>제4장 계약당사자의 의무 및 손해배상 등</h2>
<h3>제12조 (은행의 의무)</h3>
<p>&nbsp;&nbsp;① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.</p>
<p>&nbsp;&nbsp;② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.</p>
<p>&nbsp;&nbsp;③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.</p>
<p>&nbsp;&nbsp;④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
<h3>제13조 (약관 외 준칙)</h3>
<p>&nbsp;&nbsp;① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.</p>
<p>&nbsp;&nbsp;② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.</p>
<p>&nbsp;&nbsp;③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.</p>
<p>&nbsp;&nbsp;④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.</p>
<p>&nbsp;&nbsp;⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.</p>
<p>&nbsp;&nbsp;⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.</p>
<p>&nbsp;&nbsp;⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.</p>
<p>&nbsp;&nbsp;⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 다른 “이용기관”의 아이디를 부정 사용하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 선량한 풍속, 기타 사회질서를 해하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. 타인의 명예를 훼손하거나 모욕하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. 타인의 지식재산권 등의 권리를 침해하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;6. 해킹행위 또는 컴퓨터 바이러스의 유포행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위</p>
<p>&nbsp;&nbsp;⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
<h2>제5장 수수료</h2>
<h3>제14조 (수수료)</h3>
<p>&nbsp;&nbsp;① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.</p>
<p>&nbsp;&nbsp;② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.</p>
<p>&nbsp;&nbsp;③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.</p>
<p>&nbsp;&nbsp;④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.</p>
<p>&nbsp;&nbsp;⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.</p>
<p>&nbsp;&nbsp;⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.</p>
<h2>제6장 보칙</h2>
<h3>제15조 (최종사용자에 대한 고지 의무)</h3>
<p>&nbsp;&nbsp;① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.</p>
<p>&nbsp;&nbsp;② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.</p>
<p>&nbsp;&nbsp;③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.</p>
<p>&nbsp;&nbsp;④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.</p>
<h3>제16조 (부정 접속 방지)</h3>
<p>&nbsp;&nbsp;① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.</p>
<p>&nbsp;&nbsp;② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.</p>
<h3>제17조 (전산처리 절차)</h3>
<p>전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.</p>
<h3>제18조 (보안관리)</h3>
<p>&nbsp;&nbsp;① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 개인정보 및 금융정보 암호화 운영현황</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 해킹 등에 대비한 정책사항</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 시스템 운영에 대한 정보보호 체계 등</p>
<p>&nbsp;&nbsp;② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.</p>
<p>&nbsp;&nbsp;③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.</p>
<h3>제19조 (기밀유지)</h3>
<p>&nbsp;&nbsp;① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.</p>
<p>&nbsp;&nbsp;② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.</p>
<h3>제20조 (손해배상 및 면책)</h3>
<p>&nbsp;&nbsp;① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
<p>&nbsp;&nbsp;② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 접근매체의 위조나 변조로 발생한 사고</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고</p>
<p>&nbsp;&nbsp;③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.</p>
<p>&nbsp;&nbsp;④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;가. 누설·노출 또는 방치한 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우</p>
<h3>제21조 (이의제기 및 협조)</h3>
<p>&nbsp;&nbsp;① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.</p>
<p>&nbsp;&nbsp;② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.</p>
<p>&nbsp;&nbsp;③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.</p>
<p>&nbsp;&nbsp;④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.</p>
<h3>제22조 (거래기록·자료의 제공)</h3>
<p>&nbsp;&nbsp;① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.</p>
<p>&nbsp;&nbsp;② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.</p>
<p>&nbsp;&nbsp;③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.</p>
<p>&nbsp;&nbsp;④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.</p>
<p>&nbsp;&nbsp;⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 전자적 장치를 통한 제공의 경우에는 즉시</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부</p>
<h2>부칙</h2>
<p>본 약관은 2026. 99. 99.부터 시행합니다.</p>
+184
View File
@@ -0,0 +1,184 @@
제1장 총칙
제1조 (목적)
본 약관은 (주)제주은행(이하 “은행”)이 제공하는 Open API 서비스를 이용함에 있어 “은행”과 Open API 서비스를 이용하는 자(이하 “이용기관”)간의 권리, 의무 및 책임 사항, 이용조건 및 절차 등 기본적인 사항을 명확히 규정함을 목적으로 합니다.
제2조 (용어의 정의)
① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.
1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API
2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것
3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템
4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트
5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)
6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자
7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객
8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자
제3조 (서비스의 내용)
① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)
② 거래성 API 서비스(환전, 이체 등)
③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”
제4조 (약관의 효력 및 변경)
① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.
② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.
제2장 서비스 이용계약
제5조 (서비스 이용신청)
① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.
② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.
제6조 (서비스 이용신청 승인 및 거절)
① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.
② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.
1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우
2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우
3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우
4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우
5. “이용신청기관”의 사업모델에 위법성이 있는 경우
6. “이용신청기관”의 재무안정성이 위험한 경우
7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우
8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우
③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.
제3장 서비스의 이용
제7조 (서비스 이용개시)
“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.
제8조 (서비스 이용시간)
① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.
1. 은행의 시스템을 점검하는 경우
2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우
3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우
4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우
5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우
② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.
제9조 (서비스 변경 및 중단)
① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.
② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.
1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우
2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우
3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우
4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우
③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.
④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.
제10조 (서비스 이용 제한)
① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.
1. 감독당국으로부터 중지 권고가 있는 경우
2. “이용기관”이 휴업 또는 폐업을 하는 경우
3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우
4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우
5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우
6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우
② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.
제11조 (서비스 이용 해지)
① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.
1. 감독당국으로부터 해지권고가 있는 경우
2. 최근 12개월간 “시스템” 이용 실적이 없는 경우
3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우
4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우
② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.
③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.
제4장 계약당사자의 의무 및 손해배상 등
제12조 (은행의 의무)
① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.
② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.
③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.
④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.
제13조 (약관 외 준칙)
① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.
② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.
③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.
④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.
⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.
⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.
⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.
⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.
1. 다른 “이용기관”의 아이디를 부정 사용하는 행위
2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위
3. 선량한 풍속, 기타 사회질서를 해하는 행위
4. 타인의 명예를 훼손하거나 모욕하는 행위
5. 타인의 지식재산권 등의 권리를 침해하는 행위
6. 해킹행위 또는 컴퓨터 바이러스의 유포행위
7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위
8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위
⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.
제5장 수수료
제14조 (수수료)
① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.
② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.
③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.
④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.
⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.
⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.
제6장 보칙
제15조 (최종사용자에 대한 고지 의무)
① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.
② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.
③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.
④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.
제16조 (부정 접속 방지)
① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.
② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.
제17조 (전산처리 절차)
전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.
제18조 (보안관리)
① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.
1. 개인정보 및 금융정보 암호화 운영현황
2. 해킹 등에 대비한 정책사항
3. 시스템 운영에 대한 정보보호 체계 등
② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.
③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.
제19조 (기밀유지)
① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.
② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.
제20조 (손해배상 및 면책)
① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.
② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.
1. 접근매체의 위조나 변조로 발생한 사고
2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고
3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고
③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.
④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.
1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)
2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우
3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우
4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우
가. 누설·노출 또는 방치한 행위
나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위
5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우
제21조 (이의제기 및 협조)
① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.
② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.
③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.
④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.
제22조 (거래기록·자료의 제공)
① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.
② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.
③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.
④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.
⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.
1. 전자적 장치를 통한 제공의 경우에는 즉시
2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부
부칙
본 약관은 2026. 99. 99.부터 시행합니다.
+20
View File
@@ -0,0 +1,20 @@
<!--
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h1/h2/h3/p/ul/li/strong)만 사용.
admin Summernote 코드뷰에 그대로 붙여넣으면 됨. class/style 속성 없음 -> 붙여넣기(paste-cleaner)든
코드뷰 직접 입력이든 태그 정리 과정에서 잘려나갈 것이 없다.
-->
<h1>알림수신동의서</h1>
<p><strong>(주)제주은행 귀중</strong></p>
<p>귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.</p>
<h2>발송정보</h2>
<ul>
<li><strong>수집·이용 항목:</strong> 휴대폰 번호</li>
<li><strong>발송 목적:</strong> Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송</li>
<li><strong>보유·이용 기간:</strong> 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기</li>
</ul>
<p>□ 동의하지 않음  □ 동의함</p>
<p>※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.</p>
<p>※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.</p>
<p>※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.</p>
<h3>위 알림 수신에 동의하십니까?</h3>
<p>제정일자: 2026. 99. 99. 제정</p>
+22
View File
@@ -0,0 +1,22 @@
알림수신동의서
(주)제주은행 귀중
귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.
발송정보
ㄴ 수집·이용 항목: 휴대폰 번호
ㄴ 발송 목적: Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송
ㄴ 보유·이용 기간: 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기
□ 동의하지 않음 □ 동의함
※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.
※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.
※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.
위 알림 수신에 동의하십니까?
제정일자: 2026. 99. 99. 제정
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>DJBank API Portal 이메일 인증</title>
<!--[if mso]>
<style type="text/css">
table, td { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }
</style>
<![endif]-->
</head>
<body style="margin:0;padding:0;width:100%;background-color:#eef0f3;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<!-- 미리보기 텍스트 (받은편지함 목록에 노출, 본문에서는 숨김) -->
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#eef0f3;">DJBank 개발자포탈 이메일 인증번호 안내입니다.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#eef0f3;">
<tr>
<td align="center" style="padding:30px 12px;">
<!-- ============ 카드 (흰 배경 · 테두리 #dfdfdf · 라운드 20) ============ -->
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid #dfdfdf;border-radius:20px;">
<!-- ===== Header : DJ Bank 로고 + API Portal ===== -->
<tr>
<td style="padding:26px 27px 0 27px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="20" style="width:20px;font-size:0;line-height:0;">&nbsp;</td>
<td valign="middle" style="line-height:0;">
<img src="images/logo-djbank.png" width="144" height="40" alt="DJ Bank" style="display:block;width:144px;height:40px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
</td>
<td width="16" style="width:16px;font-size:0;line-height:0;">&nbsp;</td>
<td valign="middle" style="line-height:0;">
<img src="images/logo-apiportal.png" width="124" height="21" alt="API Portal" style="display:block;width:124px;height:21px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
</td>
</tr>
</table>
</td>
</tr>
<!-- ===== 본문 : 상단 파란선(#2A69DE) + 그림자 ===== -->
<tr>
<td style="padding:7px 27px 0 27px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-top:3px solid #2a69de;box-shadow:0px 4px 12px 0px rgba(85,95,108,0.25);">
<tr>
<td style="padding:26px 29px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<!-- 제목 (subject) : ##변수## 치환 토큰 -->
<tr>
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:15px;line-height:25px;color:#000000;font-weight:600;padding-bottom:20px;">##subject:이메일인증##</td>
</tr>
<!-- 인증번호 박스 (#ECF1FF · 라운드 10) -->
<tr>
<td style="padding-bottom:20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#ecf1ff;border-radius:10px;">
<tr>
<td align="center" bgcolor="#ecf1ff" style="background-color:#ecf1ff;border-radius:10px;padding:16px 20px;font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:14px;line-height:24px;color:#000000;font-weight:700;text-align:center;letter-spacing:2px;"><!-- 동적값: 인증번호 -->123456</td>
</tr>
</table>
</td>
</tr>
<!-- 안내 문구 -->
<tr>
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:12px;line-height:25px;color:#000000;padding-bottom:20px;">
안녕하세요, <!-- 동적값: 회원명 -->김철수 님.<br>
DJBank 개발자포탈 회원가입 시 입력하신 이메일을 인증해 주세요.<br>
아래 인증번호를 회원가입 화면에 입력하시면 인증이 완료됩니다.
</td>
</tr>
<!-- 경고 문구 (#FF2727) -->
<tr>
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:12px;line-height:25px;color:#ff2727;font-weight:600;">※ 본 메일을 요청하지 않으셨다면 이 메일을 무시해 주시기 바랍니다.</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- ===== Footer : 제주은행 로고 + 안내 (#F1F1ED) ===== -->
<tr>
<td style="padding:7px 27px 26px 27px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f1f1ed" style="background-color:#f1f1ed;border-radius:4px;">
<tr>
<td style="padding:12px 14px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="middle" width="86" style="width:86px;padding-right:16px;line-height:0;">
<img src="images/logo-jejubank.png" width="86" height="24" alt="제주은행" style="display:block;width:86px;height:24px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
</td>
<td valign="middle" style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;color:#0d0e11;">
<p style="margin:0;font-size:10px;line-height:20px;color:#0d0e11;">본 메일은 발신 전용으로 회신되지 않습니다.</p>
<p style="margin:0 0 9px 0;font-size:10px;line-height:20px;color:#0d0e11;">관련 문의사항은 고객센터 (????-????) 또는 홈페이지를 이용하시기 바랍니다.</p>
<p style="margin:0 0 9px 0;font-size:10px;line-height:16px;color:#0d0e11;">제주 제주시 1100로 3351 (노형동)<span style="color:#c2c2bc;">&nbsp;|&nbsp;</span>고객센터 : ????-?????<span style="color:#c2c2bc;">&nbsp;|&nbsp;</span>사업자 등록번호 : 616-81-00615</p>
<p style="margin:0;font-size:10px;line-height:16px;color:#454545;font-weight:500;">Copyright JEJUBANK. ALL Rights Reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- ============ /카드 ============ -->
</td>
</tr>
</table>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+326
View File
@@ -0,0 +1,326 @@
/*
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
*
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
*
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
* classpath 나 WAR 산출물에는 포함되지 않는다.
*
* 시트: 요약 / WAR 기준
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
*/
buildscript {
repositories {
maven {
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
allowInsecureProtocol = true
}
mavenCentral()
}
dependencies {
classpath 'org.apache.poi:poi-ooxml:3.17'
}
}
import groovy.json.JsonSlurper
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.xssf.usermodel.XSSFWorkbook
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
ext.SBOM_CELL_LIMIT = 32000
task sbomXlsx {
group = 'sbom'
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
if (!project.hasProperty('sbomJson')) {
dependsOn 'cyclonedxBom'
}
doLast {
File src = resolveBomJson(project)
File out = project.hasProperty('sbomOut')
? project.file(project.property('sbomOut'))
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
out.parentFile.mkdirs()
def bom = new JsonSlurper().parse(src, 'UTF-8')
def deploy = collectDeployJars(project)
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
def wb = new XSSFWorkbook()
def st = createStyles(wb)
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
writeWarSheet(wb, st, warRows)
out.withOutputStream { os -> wb.write(os) }
wb.close()
int unmatched = warRows.count { it.matched == 'N' }
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
}
}
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
String sbomFileName(Project p) {
def warTask = p.tasks.findByName('war')
if (warTask != null) {
String archive = warTask.archiveFileName.get()
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
}
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
return "${p.name}${ver}-sbom.xlsx"
}
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
File resolveBomJson(Project p) {
if (p.hasProperty('sbomJson')) {
File f = p.file(p.property('sbomJson'))
if (!f.exists()) {
throw new GradleException("bom.json 없음: ${f.absolutePath}")
}
return f
}
def candidates = [
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
new File(p.buildDir, 'reports/bom.json'),
]
File found = candidates.find { it.exists() }
if (found == null) {
throw new GradleException(
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
}
return found
}
/**
* 실제 배포물에 packaging 되는 jar 목록.
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
*
* @return [label: 기준 설명, jars: 행 목록]
*/
Map collectDeployJars(Project p) {
def cfg = p.configurations.findByName('runtimeClasspath')
if (cfg == null) {
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
return [label: '(없음)', jars: []]
}
def warTask = p.tasks.findByName('war')
def files
String label
if (warTask != null) {
files = warTask.classpath.files
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
} else {
files = cfg.files
label = 'runtimeClasspath (war 태스크 없음)'
}
def coordByFile = [:]
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
def id = a.moduleVersion.id
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
}
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
def c = coordByFile[f]
[
file : f.name,
group : c?.group ?: '',
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
version: c?.version ?: '',
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
]
}.sort { it.file }
return [label: label, jars: jars]
}
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
Map indexComponents(bom) {
String rootRef = bom.metadata?.component?.'bom-ref'
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
def index = [:]
bom.components?.each { c ->
def hashes = [:]
c.hashes?.each { h -> hashes[h.alg] = h.content }
def licenses = (c.licenses ?: []).collect { l ->
l.license?.id ?: l.license?.name ?: l.expression ?: ''
}.findAll { it }
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
licenses : licenses.join('; '),
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
purl : c.purl ?: '',
sha256 : hashes['SHA-256'] ?: '',
sha1 : hashes['SHA-1'] ?: '',
description: c.description ?: '',
]
}
return index
}
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
List joinWarRows(List warJars, Map index) {
def result = []
warJars.eachWithIndex { j, i ->
def m = j.coord ? index[j.coord] : null
result << [
no : i + 1,
file : j.file,
group : j.group,
name : j.name,
version : j.version,
direct : m?.direct ?: '',
licenses : m?.licenses ?: '',
licenseList: m?.licenseList ?: ['(미상)'],
purl : m?.purl ?: '',
sha256 : m?.sha256 ?: '',
sha1 : m?.sha1 ?: '',
matched : (m != null) ? 'Y' : 'N',
description: m?.description ?: '',
]
}
return result
}
Map createStyles(wb) {
def headFont = wb.createFont()
headFont.setBold(true)
headFont.setColor(IndexedColors.WHITE.getIndex())
def head = wb.createCellStyle()
head.setFont(headFont)
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
head.setAlignment(HorizontalAlignment.CENTER)
head.setVerticalAlignment(VerticalAlignment.CENTER)
head.setBorderBottom(BorderStyle.THIN)
def body = wb.createCellStyle()
body.setVerticalAlignment(VerticalAlignment.TOP)
def wrap = wb.createCellStyle()
wrap.setVerticalAlignment(VerticalAlignment.TOP)
wrap.setWrapText(true)
def labelFont = wb.createFont()
labelFont.setBold(true)
def label = wb.createCellStyle()
label.setFont(labelFont)
return [head: head, body: body, wrap: wrap, label: label]
}
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
def row = sheet.createRow(0)
row.setHeightInPoints(20f)
headers.eachWithIndex { h, i ->
def cell = row.createCell(i)
cell.setCellValue(h)
cell.setCellStyle(style)
sheet.setColumnWidth(i, widths[i] * 256)
}
sheet.createFreezePane(0, 1)
}
def cellOf(row, int idx, value, style) {
def cell = row.createCell(idx)
String s = (value == null) ? '' : value.toString()
if (s.length() > SBOM_CELL_LIMIT) {
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
}
cell.setCellValue(s)
cell.setCellStyle(style)
return cell
}
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
def sheet = wb.createSheet('요약')
def comp = bom.metadata?.component ?: [:]
def tool = bom.metadata?.tools?.components?.getAt(0)
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
def items = [
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
['serialNumber', bom.serialNumber ?: ''],
['생성 시각', bom.metadata?.timestamp ?: ''],
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
['원본 파일', src.absolutePath],
['배포 수록 jar', warRows.size()],
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
['라이선스 종류', licenseKinds.size()],
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
]
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
items.eachWithIndex { item, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, item[0], st.label)
cellOf(row, 1, item[1], st.body)
}
// 라이선스 분포 (요약 하단)
def byLicense = [:].withDefault { 0 }
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
int base = items.size() + 2
def hdr = sheet.createRow(base)
cellOf(hdr, 0, '라이선스', st.head)
cellOf(hdr, 1, 'jar 수', st.head)
sorted.eachWithIndex { e, i ->
def row = sheet.createRow(base + 1 + i)
cellOf(row, 0, e.key, st.body)
cellOf(row, 1, e.value, st.body)
}
}
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
def writeWarSheet(wb, st, List warRows) {
def sheet = wb.createSheet('WAR 기준')
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
writeHeader(sheet, st.head, headers, widths)
warRows.eachWithIndex { r, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, r.no, st.body)
cellOf(row, 1, r.file, st.body)
cellOf(row, 2, r.group, st.body)
cellOf(row, 3, r.name, st.body)
cellOf(row, 4, r.version, st.body)
cellOf(row, 5, r.direct, st.body)
cellOf(row, 6, r.licenses, st.body)
cellOf(row, 7, r.purl, st.body)
cellOf(row, 8, r.sha256, st.body)
cellOf(row, 9, r.sha1, st.body)
cellOf(row, 10, r.matched, st.body)
cellOf(row, 11, r.description, st.wrap)
}
if (!warRows.isEmpty()) {
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
}
}
@@ -0,0 +1,85 @@
# 메뉴 관리 개발 가이드
포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
## 구성 요소
| 구성 | 위치 | 역할 |
|---|---|---|
| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
## menu.yml 스키마
```yaml
portal-menu:
items:
- id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
name: "고객지원"
group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
children:
- { id: support-faq, name: "FAQ", path: /faq_list }
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
```
- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
## 시딩 규칙 (MenuSeeder)
1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
**관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
2. **배치**: `PTL_MENU_PLACEMENT`**비어있을 때만** 기본 배치로 최초 시딩.
이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
## 캐시와 리로드
- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
(admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
+ X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
## 새 메뉴 추가 절차
**기본 메뉴(코드 배포와 함께)**
1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
커스텀 항목은 미배치 시 삭제된다.
## 로컬 개발 주의
- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
`JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
(미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
## 역할(roles.yml) 변경
- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
admin 권한 선택 체크박스 소스 전용.
- 역할 추가 시 `roles.yml``authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
+112
View File
@@ -0,0 +1,112 @@
# DJB 커스텀 소스 납품본 수집
발주사 납품용으로, 기준 시점 이후 **신규로 추가된 커스텀 소스 파일**만 모아
디렉터리 구조를 유지한 채 한곳에 복사하고 `MANIFEST.csv` / `SUMMARY.md` / zip 을 만든다.
## 파일
| 파일 | 용도 |
|---|---|
| `export-custom.sh` | macOS / Linux 실행 스크립트 |
| `export-custom.ps1` | Windows PowerShell 실행 스크립트 (실제 로직) |
| `export-custom.bat` | Windows cmd 래퍼 — 인자를 `.ps1` 로 그대로 전달 |
| `modules.conf` | 대상 모듈 / 소스 경로 / 수집 확장자 |
| `export-filter.rules` | 포함·제외 규칙 (`.gitignore` 유사 문법) |
| `filelist.txt` | 마지막으로 뽑은 수집 대상 목록 (검토용, 재생성 가능) |
| `filelist-no-djb-marker.txt` | 그중 `djb` / `custom` 마커가 **없는** 파일 — 업그레이드 유입분 검토 대상 |
## 수집 방식
1. 모듈별로 **컷오프 날짜 직전의 마지막 커밋**을 base 로 잡는다.
`git rev-list -1 --before=<CUTOFF> HEAD`
컷오프 이전 커밋이 아예 없으면 저장소 전체를 신규로 본다.
2. `git diff --name-only --diff-filter=A -M <base> HEAD -- <소스경로>`
**신규 추가(A)** 파일만 뽑는다. `-M` 이라 단순 rename 은 신규로 잡히지 않는다.
3. `modules.conf` 의 확장자 allowlist 로 거른다. (확장자 없는 파일은 제외)
4. `export-filter.rules` 로 포함·제외를 최종 결정한다.
5. 남은 파일을 `<out>/src/<모듈>/<원래 경로>` 로 복사한다.
## 사용법
```bash
# macOS / Linux
./export-custom.sh --dry-run > filelist.txt # 목록만 뽑기 (로그는 stderr)
./export-custom.sh --show-excluded # 규칙에 걸려 빠진 목록 + 걸린 규칙
./export-custom.sh # 실제 수집
./export-custom.sh --zip # 수집 + zip
./export-custom.sh -c 2026-05-01 -m eapim-portal
```
```powershell
# Windows
.\export-custom.ps1 -DryRun > filelist.txt
.\export-custom.ps1 -ShowExcluded
.\export-custom.ps1 -Zip
.\export-custom.ps1 -Cutoff 2026-05-01 -Module eapim-portal,eapim-admin
# cmd 에서
export-custom.bat -DryRun > filelist.txt
```
### 주요 옵션
| sh | ps1 | 설명 |
|---|---|---|
| `-c, --cutoff` | `-Cutoff` | 기준 날짜 (기본 `2026-05-01`) |
| `-r, --root` | `-Root` | 저장소 루트. 미지정 시 스크립트 위치 기준 자동 탐지 |
| `-o, --out` | `-Out` | 출력 디렉터리 (기본 `<root>/build/djb-custom-export`) |
| `-m, --module` | `-Module` | 특정 모듈만 |
| `--zip` | `-Zip` | zip 생성 |
| `--include-untracked` | `-IncludeUntracked` | 미커밋 신규 파일도 포함 |
| `--dry-run` | `-DryRun` | 복사 없이 목록만 |
| `--show-excluded` | `-ShowExcluded` | 규칙에 걸려 빠진 목록 |
## `export-filter.rules` 문법
`.gitignore` 와 비슷하되 더 단순하다.
- 한 줄에 패턴 하나. `#` 주석, 빈 줄 무시
- 기본은 **모두 포함**. 패턴에 걸리면 제외
- `!` 로 시작하면 예외(다시 포함)
- **뒤에 오는 규칙이 앞 규칙을 덮는다** — 순서가 중요하다
- 패턴에 `/` 가 있으면 `<모듈>/<경로>` 전체와 매칭, 없으면 파일명만 매칭
- `/` 로 끝나면 그 디렉터리 하위 전체
- `*``/` 도 포함해 매칭한다 (단순 glob, `**` 없음)
매칭 대상 경로 예:
```
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/WebhookService.java
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookList.jsp
```
규칙 파일은 세 구역으로 나뉜다.
1. **업그레이드 유입분 제외**`filelist-no-djb-marker.txt` 를 검토하며 채운다
2. **`!*djb*` 예외** — djb 마커가 붙은 파일은 커스텀이므로 되살린다
3. **무조건 제외** — 빌드 산출물·서드파티. 2)보다 뒤에 있어 최종 승리한다
## 신규지만 커스텀이 아닌 파일 걸러내기
신규 파일이라도 제품 업그레이드로 딸려 온 것일 수 있다. 판별 순서:
```bash
# 1) 전체 목록
./export-custom.sh --dry-run > filelist.txt
# 2) djb / custom 마커가 없는 것 = 검토 대상
grep -vi djb filelist.txt | grep -v '/custom/' > filelist-no-djb-marker.txt
# 3) 검토 후 업그레이드 유입분을 export-filter.rules 의 1) 구역에 추가
# 4) 반영 확인
./export-custom.sh --show-excluded
```
## 주의
- 컷오프 이전 커밋이 없으면 base 가 **빈 트리**가 되어 저장소 전체가 신규로 잡힌다.
`SUMMARY.md` 의 base 컬럼에 `(컷오프 이전 커밋 없음 …)` 으로 표시된다.
- 모듈에 컷오프 직전 커밋이 드문드문 있으면 base 가 컷오프보다 훨씬 이전으로 잡힐 수 있다.
실행 로그의 `base:` 날짜를 항상 확인할 것.
- 추가된 뒤 삭제·이동된 파일은 현재 트리에 없으므로 건너뛰고, 건수를 `SUMMARY.md` 에 남긴다.
@@ -0,0 +1,29 @@
@echo off
REM ---------------------------------------------------------------------------
REM DJB 커스텀 소스 납품본 수집 (Windows cmd 래퍼)
REM
REM 실제 로직은 export-custom.ps1 에 있다. 이 배치는 PowerShell 을 띄우고
REM 전달받은 인자를 그대로 넘기기만 한다.
REM
REM 사용 예:
REM export-custom.bat
REM export-custom.bat -Cutoff 2026-05-01 -Zip
REM export-custom.bat -DryRun > filelist.txt
REM export-custom.bat -ShowExcluded
REM export-custom.bat -Module eapim-portal,eapim-admin
REM ---------------------------------------------------------------------------
setlocal
REM 한글 경로/출력이 깨지지 않도록 코드페이지를 UTF-8 로 전환
chcp 65001 >nul
where powershell >nul 2>nul
if errorlevel 1 (
echo [오류] PowerShell 을 찾을 수 없습니다. 1>&2
exit /b 1
)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0export-custom.ps1" %*
set RC=%ERRORLEVEL%
endlocal & exit /b %RC%
+278
View File
@@ -0,0 +1,278 @@
<#
.SYNOPSIS
DJB 커스텀 소스 납품본 수집 스크립트 (Windows / PowerShell)
.DESCRIPTION
기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아
모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고,
MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다.
.EXAMPLE
.\export-custom.ps1
.\export-custom.ps1 -Cutoff 2026-05-01 -Zip
.\export-custom.ps1 -DryRun
.\export-custom.ps1 -Module eapim-portal,eapim-admin
#>
[CmdletBinding()]
param(
[string] $Cutoff = '2026-05-01',
[string] $Root,
[string] $Out,
[string[]] $Module,
[switch] $Zip,
[switch] $IncludeUntracked,
[switch] $DryRun,
[switch] $ShowExcluded
)
if ($ShowExcluded) { $DryRun = $true }
$ErrorActionPreference = 'Stop'
# git 출력의 한글이 깨지지 않도록 콘솔 인코딩을 UTF-8로 고정한다.
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { }
$OutputEncoding = [Text.Encoding]::UTF8
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ConfFile = Join-Path $ScriptDir 'modules.conf'
$RulesFile = Join-Path $ScriptDir 'export-filter.rules'
# 저장소 루트: 스크립트는 <root>\eapim-portal\script\djb-custom-export\ 에 있다.
if (-not $Root) { $Root = (Resolve-Path (Join-Path $ScriptDir '..\..\..')).Path }
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
throw "저장소 루트를 찾을 수 없다: $Root"
}
if (-not $Out) { $Out = Join-Path $Root 'build\djb-custom-export' }
if (-not (Test-Path -LiteralPath $ConfFile)) { throw "설정 파일 없음: $ConfFile" }
# 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다.
$EmptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
# ---- 설정 로드 ------------------------------------------------------------
$Modules = @()
$AllowExt = @()
foreach ($line in (Get-Content -LiteralPath $ConfFile -Encoding UTF8)) {
$t = $line.Trim()
if ($t -eq '' -or $t.StartsWith('#')) { continue }
$idx = $t.IndexOf('|')
if ($idx -lt 0) { continue }
$key = $t.Substring(0, $idx)
$rest = $t.Substring($idx + 1)
switch ($key) {
'MODULE' {
$i2 = $rest.IndexOf('|')
if ($i2 -lt 0) { continue }
$Modules += [pscustomobject]@{
Name = $rest.Substring(0, $i2)
Paths = $rest.Substring($i2 + 1).Split(' ') | Where-Object { $_ -ne '' }
}
}
'EXT' { $AllowExt = $rest.Split(',') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ -ne '' } }
}
}
# ---- 포함/제외 규칙 로드 (.gitignore 유사) --------------------------------
$Rules = @()
if (Test-Path -LiteralPath $RulesFile) {
foreach ($line in (Get-Content -LiteralPath $RulesFile -Encoding UTF8)) {
$t = $line.Trim()
if ($t -eq '' -or $t.StartsWith('#')) { continue }
$Rules += $t
}
}
# 마지막으로 매칭된 규칙이 이긴다. 기본 포함, '!' 는 예외(다시 포함).
# 반환: @{ Keep = $true/$false; Rule = '<제외시킨 규칙>' }
function Test-RuleKeep([string]$RelPath) {
$leaf = Split-Path $RelPath -Leaf
$keep = $true
$matched = ''
foreach ($raw in $Rules) {
$rule = $raw
$neg = $false
if ($rule.StartsWith('!')) { $neg = $true; $rule = $rule.Substring(1) }
if ($rule.EndsWith('/')) { $rule = $rule + '*' } # 디렉터리 규칙
$target = if ($rule.Contains('/')) { $RelPath } else { $leaf }
if ($target -like $rule) {
if ($neg) { $keep = $true; $matched = '' }
else { $keep = $false; $matched = $rule }
}
}
return @{ Keep = $keep; Rule = $matched }
}
function Test-AllowedExt([string]$RelPath) {
$leaf = Split-Path $RelPath -Leaf
if ($leaf -notmatch '\.') { return $false } # 확장자 없는 파일 제외
$ext = $leaf.Substring($leaf.LastIndexOf('.') + 1).ToLower()
return $AllowExt -contains $ext
}
function Invoke-Git([string]$Dir, [string[]]$GitArgs) {
$all = @('-C', $Dir, '-c', 'core.quotepath=false') + $GitArgs
$res = & git @all 2>$null
if ($LASTEXITCODE -ne 0) { return @() }
return @($res)
}
# ---- 준비 -----------------------------------------------------------------
$Manifest = Join-Path $Out 'MANIFEST.csv'
$Summary = Join-Path $Out 'SUMMARY.md'
if (-not $DryRun) {
if (Test-Path -LiteralPath $Out) { Remove-Item -LiteralPath $Out -Recurse -Force }
New-Item -ItemType Directory -Path (Join-Path $Out 'src') -Force | Out-Null
# Excel 한글용 UTF-8 BOM
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[IO.File]::WriteAllText($Manifest, "module,path,ext,added_commit,added_date,author`r`n", $utf8Bom)
}
Write-Host "저장소 루트 : $Root"
Write-Host "기준 날짜 : $Cutoff (이 날짜 직전 마지막 커밋이 base)"
Write-Host "출력 경로 : $Out"
if ($DryRun) { Write-Host '모드 : DRY-RUN (복사 안 함)' }
Write-Host ''
$Rows = New-Object System.Collections.Generic.List[string]
$SummaryRs = New-Object System.Collections.Generic.List[object]
$Total = 0
$Missing = 0
$Excluded = 0
# ---- 모듈 순회 ------------------------------------------------------------
foreach ($m in $Modules) {
if ($Module -and ($Module -notcontains $m.Name)) { continue }
$modDir = Join-Path $Root $m.Name
if (-not (Test-Path -LiteralPath (Join-Path $modDir '.git'))) {
Write-Host "[건너뜀] $($m.Name) — git 저장소 아님 ($modDir)"
continue
}
# base 커밋 결정
$base = (Invoke-Git $modDir @('rev-list', '-1', "--before=$Cutoff", 'HEAD') | Select-Object -First 1)
if ([string]::IsNullOrWhiteSpace($base)) {
$base = $EmptyTree
$baseDesc = '(컷오프 이전 커밋 없음 → 전체를 신규로 간주)'
} else {
$d = (Invoke-Git $modDir @('log', '-1', '--format=%h %ad %s', '--date=short', $base) | Select-Object -First 1)
$baseDesc = if ($d.Length -gt 80) { $d.Substring(0, 80) } else { $d }
}
# 실제 존재하는 소스 경로만 pathspec 으로 사용
$spec = @()
foreach ($p in $m.Paths) {
if (Test-Path -LiteralPath (Join-Path $modDir ($p -replace '/', '\')) -PathType Container) { $spec += $p }
}
if ($spec.Count -eq 0) {
Write-Host "[건너뜀] $($m.Name) — 설정된 소스 경로가 존재하지 않음"
continue
}
# 신규(Added) 파일만. -M 으로 rename 은 신규에서 제외한다.
$files = @(Invoke-Git $modDir (@('diff', '--name-only', '--diff-filter=A', '-M', $base, 'HEAD', '--') + $spec))
if ($IncludeUntracked) {
$files += @(Invoke-Git $modDir (@('ls-files', '--others', '--exclude-standard', '--') + $spec))
}
$files = $files | Where-Object { $_ -ne '' } | Sort-Object -Unique
$count = 0
$miss = 0
foreach ($f in $files) {
if (-not (Test-AllowedExt $f)) { continue }
# 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준
$rk = Test-RuleKeep "$($m.Name)/$f"
if (-not $rk.Keep) {
$Excluded++
if ($ShowExcluded) { Write-Output "$($m.Name)/$f`t# $($rk.Rule)" }
continue
}
if ($ShowExcluded) { continue }
$src = Join-Path $modDir ($f -replace '/', '\')
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
# 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님
Write-Warning " [없음] $($m.Name)/$f"
$miss++
continue
}
$count++
# 목록은 Write-Output(성공 스트림) 으로 — 리다이렉트하면 목록만 파일로 떨어진다.
if ($DryRun) { Write-Output "$($m.Name)/$f"; continue }
$dst = Join-Path (Join-Path $Out 'src') (Join-Path $m.Name ($f -replace '/', '\'))
$dstDir = Split-Path -Parent $dst
if (-not (Test-Path -LiteralPath $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null }
Copy-Item -LiteralPath $src -Destination $dst -Force
$meta = (Invoke-Git $modDir @('log', '-1', '--diff-filter=A', '--format=%h|%ad|%an', '--date=short', '--', $f) | Select-Object -First 1)
$cHash = ''; $cDate = ''; $cAuth = ''
if ($meta) {
$parts = $meta.Split('|')
if ($parts.Count -ge 3) { $cHash = $parts[0]; $cDate = $parts[1]; $cAuth = $parts[2] }
}
$leaf = Split-Path $f -Leaf
$ext = if ($leaf -match '\.') { $leaf.Substring($leaf.LastIndexOf('.') + 1) } else { '' }
$esc = { param($s) '"' + ($s -replace '"', '""') + '"' }
$Rows.Add((@(
(& $esc $m.Name), (& $esc $f), (& $esc $ext),
(& $esc $cHash), (& $esc $cDate), (& $esc $cAuth)
) -join ','))
}
$Total += $count
$Missing += $miss
$SummaryRs.Add([pscustomobject]@{ Module = $m.Name; Count = $count; Base = $baseDesc })
Write-Host ("[수집] {0,-22} {1,5} 개 base: {2}" -f $m.Name, $count, $baseDesc)
}
# ---- 출력 ------------------------------------------------------------------
if (-not $DryRun) {
if ($Rows.Count -gt 0) {
[IO.File]::AppendAllText($Manifest, (($Rows -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false)))
}
$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine('# DJB 커스텀 소스 납품본')
[void]$sb.AppendLine('')
[void]$sb.AppendLine("- 생성 일시: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
[void]$sb.AppendLine("- 기준 날짜: ``$Cutoff`` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)")
[void]$sb.AppendLine('- 수집 규칙: `git diff --diff-filter=A -M <base> HEAD` / 확장자 allowlist / `export-filter.rules` 필터')
[void]$sb.AppendLine("- 대상 확장자: ``$($AllowExt -join ',')``")
[void]$sb.AppendLine("- 규칙 파일: ``$(Split-Path $RulesFile -Leaf)`` ($($Rules.Count) 개 규칙, 제외 $Excluded 건)")
[void]$sb.AppendLine('')
[void]$sb.AppendLine('## 모듈별 수집 결과')
[void]$sb.AppendLine('')
[void]$sb.AppendLine('| 모듈 | 파일 수 | base 커밋 |')
[void]$sb.AppendLine('|---|---:|---|')
foreach ($r in $SummaryRs) { [void]$sb.AppendLine("| $($r.Module) | $($r.Count) | $($r.Base) |") }
[void]$sb.AppendLine('')
[void]$sb.AppendLine("**합계: $Total 개**")
if ($Missing -gt 0) { [void]$sb.AppendLine("> 추가 후 삭제/이동되어 현재 트리에 없는 파일 $Missing 개는 제외됨.") }
[void]$sb.AppendLine('')
[void]$sb.AppendLine('## 파일 목록')
[void]$sb.AppendLine('')
[void]$sb.AppendLine('`MANIFEST.csv` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자).')
[IO.File]::WriteAllText($Summary, $sb.ToString(), (New-Object System.Text.UTF8Encoding($true)))
Write-Host ''
Write-Host "MANIFEST : $Manifest"
Write-Host "SUMMARY : $Summary"
if ($Zip) {
$zipName = "djb-custom-export-$(Get-Date -Format 'yyyyMMdd').zip"
$zipPath = Join-Path (Split-Path -Parent $Out) $zipName
if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
Compress-Archive -Path (Join-Path $Out '*') -DestinationPath $zipPath
Write-Host "ZIP : $zipPath"
}
}
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env bash
#
# DJB 커스텀 소스 납품본 수집 스크립트 (macOS / Linux)
#
# 기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아
# 모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고,
# MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다.
#
# 사용법:
# ./export-custom.sh # 기본값으로 실행
# ./export-custom.sh -c 2026-05-01 --zip
# ./export-custom.sh --dry-run
# ./export-custom.sh -m eapim-portal -m eapim-admin
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF_FILE="$SCRIPT_DIR/modules.conf"
RULES_FILE="$SCRIPT_DIR/export-filter.rules"
# ---- 기본값 ---------------------------------------------------------------
CUTOFF="2026-05-01"
REPO_ROOT=""
OUT_DIR=""
MAKE_ZIP=0
DRY_RUN=0
INCLUDE_UNTRACKED=0
ONLY_MODULES=""
SHOW_EXCLUDED=0
usage() {
cat <<'EOF'
DJB 커스텀 소스 납품본 수집
옵션:
-c, --cutoff <YYYY-MM-DD> 기준 날짜. 이 날짜 직전 마지막 커밋을 base로 삼는다.
(기본: 2026-05-01)
-r, --root <DIR> 저장소 루트(djb-eapim). 미지정 시 스크립트 위치에서 자동 탐지.
-o, --out <DIR> 출력 디렉터리. (기본: <root>/build/djb-custom-export)
-m, --module <NAME> 특정 모듈만 수집. 여러 번 지정 가능.
--zip 수집 후 zip 아카이브 생성.
--include-untracked git에 아직 커밋되지 않은 신규 파일도 포함.
--dry-run 복사하지 않고 대상 목록만 stdout 으로 출력(로그는 stderr).
--show-excluded --dry-run 과 함께. 규칙에 걸려 제외된 목록을 대신 출력.
-h, --help 도움말.
포함/제외 규칙은 export-filter.rules (.gitignore 유사 문법) 에서 관리한다.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-c|--cutoff) CUTOFF="$2"; shift 2 ;;
-r|--root) REPO_ROOT="$2"; shift 2 ;;
-o|--out) OUT_DIR="$2"; shift 2 ;;
-m|--module) ONLY_MODULES="$ONLY_MODULES $2"; shift 2 ;;
--zip) MAKE_ZIP=1; shift ;;
--include-untracked) INCLUDE_UNTRACKED=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--show-excluded) SHOW_EXCLUDED=1; DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "알 수 없는 옵션: $1" >&2; usage >&2; exit 1 ;;
esac
done
# ---- 저장소 루트 탐지 -----------------------------------------------------
# 스크립트는 <root>/eapim-portal/script/djb-custom-export/ 에 있다.
if [ -z "$REPO_ROOT" ]; then
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
fi
if [ ! -d "$REPO_ROOT" ]; then
echo "저장소 루트를 찾을 수 없다: $REPO_ROOT" >&2
exit 1
fi
[ -z "$OUT_DIR" ] && OUT_DIR="$REPO_ROOT/build/djb-custom-export"
if [ ! -f "$CONF_FILE" ]; then
echo "설정 파일 없음: $CONF_FILE" >&2
exit 1
fi
# ---- 설정 로드 ------------------------------------------------------------
ALLOW_EXT=""
EXCLUDE_PAT=""
MODULE_LINES=""
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|'#'*) continue ;;
esac
key="${line%%|*}"
rest="${line#*|}"
case "$key" in
MODULE) MODULE_LINES="$MODULE_LINES
$rest" ;;
EXT) ALLOW_EXT="$rest" ;;
EXCLUDE) EXCLUDE_PAT="$rest" ;;
esac
done < "$CONF_FILE"
# 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다.
EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
is_allowed_ext() {
_f="$1"
_base="${_f##*/}"
case "$_base" in
*.*) _ext="${_base##*.}" ;;
*) return 1 ;; # 확장자 없는 파일 제외
esac
# 소문자 변환 (bash 3.2 호환)
_ext="$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')"
case ",$ALLOW_EXT," in
*",$_ext,"*) return 0 ;;
*) return 1 ;;
esac
}
# ---- 포함/제외 규칙 (.gitignore 유사) --------------------------------------
RULES=()
if [ -f "$RULES_FILE" ]; then
while IFS= read -r r || [ -n "$r" ]; do
# 앞뒤 공백 제거
r="${r#"${r%%[![:space:]]*}"}"
r="${r%"${r##*[![:space:]]}"}"
case "$r" in
''|'#'*) continue ;;
esac
RULES[${#RULES[@]}]="$r"
done < "$RULES_FILE"
fi
# 경로 하나에 규칙을 순서대로 적용한다. 마지막으로 매칭된 규칙이 이긴다.
# 기본은 포함(0), '!' 규칙은 다시 포함, 일반 규칙은 제외(1).
# 반환: 0 = 포함, 1 = 제외. 제외시킨 규칙은 MATCHED_RULE 에 담긴다.
MATCHED_RULE=""
rule_keep() {
_path="$1"
_leaf="${_path##*/}"
_keep=0
MATCHED_RULE=""
_i=0
while [ $_i -lt ${#RULES[@]} ]; do
_rule="${RULES[$_i]}"
_i=$((_i + 1))
_neg=0
case "$_rule" in
'!'*) _neg=1; _rule="${_rule#!}" ;;
esac
# 디렉터리 규칙: 하위 전체
case "$_rule" in
*/) _rule="${_rule}*" ;;
esac
# '/' 가 있으면 전체 경로, 없으면 파일명과 매칭
case "$_rule" in
*/*) _target="$_path" ;;
*) _target="$_leaf" ;;
esac
# shellcheck disable=SC2254 # 의도적으로 unquoted — glob 패턴으로 쓴다
case "$_target" in
$_rule)
if [ $_neg -eq 1 ]; then
_keep=0; MATCHED_RULE=""
else
_keep=1; MATCHED_RULE="$_rule"
fi
;;
esac
done
return $_keep
}
csv_escape() {
printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')"
}
# ---- 준비 -----------------------------------------------------------------
if [ "$DRY_RUN" -eq 0 ]; then
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/src"
fi
MANIFEST="$OUT_DIR/MANIFEST.csv"
SUMMARY="$OUT_DIR/SUMMARY.md"
TMP_SUMMARY="$(mktemp)"
trap 'rm -f "$TMP_SUMMARY"' EXIT
if [ "$DRY_RUN" -eq 0 ]; then
printf '\357\273\277' > "$MANIFEST" # Excel 한글용 UTF-8 BOM
echo 'module,path,ext,added_commit,added_date,author' >> "$MANIFEST"
fi
# dry-run(목록 뽑기) 모드에서는 파일 목록만 stdout으로 내보내고
# 진행 로그는 전부 stderr로 보낸다. 그래야 파이프/리다이렉트로 목록을 바로 가공할 수 있다.
if [ "$DRY_RUN" -eq 1 ]; then LOG=2; else LOG=1; fi
{
echo "저장소 루트 : $REPO_ROOT"
echo "기준 날짜 : $CUTOFF (이 날짜 직전 마지막 커밋이 base)"
echo "출력 경로 : $OUT_DIR"
[ "$DRY_RUN" -eq 1 ] && echo "모드 : DRY-RUN (목록만 출력, 복사 안 함)"
echo
} >&$LOG
TOTAL=0
MISSING=0
EXCLUDED=0
# ---- 모듈 순회 ------------------------------------------------------------
# 파이프 대신 process substitution 을 써서 집계 변수가 서브셸에 갇히지 않게 한다.
while IFS= read -r mline; do
[ -z "$mline" ] && continue
MOD="${mline%%|*}"
PATHS="${mline#*|}"
if [ -n "$ONLY_MODULES" ]; then
case " $ONLY_MODULES " in
*" $MOD "*) : ;;
*) continue ;;
esac
fi
MOD_DIR="$REPO_ROOT/$MOD"
if [ ! -d "$MOD_DIR/.git" ]; then
echo "[건너뜀] $MOD — git 저장소 아님 ($MOD_DIR)" >&$LOG
continue
fi
# base 커밋 결정
BASE="$(git -C "$MOD_DIR" -c core.quotepath=false rev-list -1 --before="$CUTOFF" HEAD 2>/dev/null || true)"
if [ -z "$BASE" ]; then
BASE="$EMPTY_TREE"
BASE_DESC="(컷오프 이전 커밋 없음 → 전체를 신규로 간주)"
else
BASE_DESC="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --format='%h %ad %s' --date=short "$BASE" | cut -c1-80)"
fi
# 실제 존재하는 소스 경로만 pathspec으로 사용
SPEC=""
for p in $PATHS; do
[ -d "$MOD_DIR/$p" ] && SPEC="$SPEC $p"
done
if [ -z "$SPEC" ]; then
echo "[건너뜀] $MOD — 설정된 소스 경로가 존재하지 않음" >&$LOG
continue
fi
# 신규(Added) 파일만. -M 으로 rename은 신규에서 제외한다.
FILES="$(git -C "$MOD_DIR" -c core.quotepath=false diff --name-only --diff-filter=A -M "$BASE" HEAD -- $SPEC 2>/dev/null || true)"
if [ "$INCLUDE_UNTRACKED" -eq 1 ]; then
UNTRACKED="$(git -C "$MOD_DIR" -c core.quotepath=false ls-files --others --exclude-standard -- $SPEC 2>/dev/null || true)"
FILES="$FILES
$UNTRACKED"
fi
COUNT=0
MISS=0
while IFS= read -r f; do
[ -z "$f" ] && continue
is_allowed_ext "$f" || continue
# 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준
if ! rule_keep "$MOD/$f"; then
EXCLUDED=$((EXCLUDED + 1))
[ "$SHOW_EXCLUDED" -eq 1 ] && printf '%s\t# %s\n' "$MOD/$f" "$MATCHED_RULE"
continue
fi
[ "$SHOW_EXCLUDED" -eq 1 ] && continue
SRC="$MOD_DIR/$f"
if [ ! -f "$SRC" ]; then
# 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님
echo " [없음] $MOD/$f" >&2
MISS=$((MISS + 1))
continue
fi
COUNT=$((COUNT + 1))
if [ "$DRY_RUN" -eq 1 ]; then
echo "$MOD/$f" # 목록은 stdout으로 (가공하기 쉽게 접두 공백 없음)
continue
fi
DST="$OUT_DIR/src/$MOD/$f"
mkdir -p "$(dirname "$DST")"
cp -p "$SRC" "$DST"
META="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --diff-filter=A --format='%h|%ad|%an' --date=short -- "$f" 2>/dev/null || true)"
C_HASH="${META%%|*}"; _r="${META#*|}"
C_DATE="${_r%%|*}"
C_AUTH="${_r#*|}"
BASE_F="${f##*/}"
case "$BASE_F" in *.*) EXT="${BASE_F##*.}" ;; *) EXT="" ;; esac
{
csv_escape "$MOD"; printf ','
csv_escape "$f"; printf ','
csv_escape "$EXT"; printf ','
csv_escape "$C_HASH"; printf ','
csv_escape "$C_DATE"; printf ','
csv_escape "$C_AUTH"; printf '\n'
} >> "$MANIFEST"
done < <(printf '%s\n' "$FILES" | sort -u)
TOTAL=$((TOTAL + COUNT))
MISSING=$((MISSING + MISS))
printf '%s|%s|%s\n' "$MOD" "$COUNT" "$BASE_DESC" >> "$TMP_SUMMARY"
printf '[수집] %-22s %5s 개 base: %s\n' "$MOD" "$COUNT" "$BASE_DESC" >&$LOG
done < <(printf '%s\n' "$MODULE_LINES")
# ---- 요약 ------------------------------------------------------------------
if [ "$DRY_RUN" -eq 0 ]; then
{
echo "# DJB 커스텀 소스 납품본"
echo
echo "- 생성 일시: $(date '+%Y-%m-%d %H:%M:%S')"
echo "- 기준 날짜: \`$CUTOFF\` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)"
echo "- 수집 규칙: \`git diff --diff-filter=A -M <base> HEAD\` / 확장자 allowlist / \`export-filter.rules\` 필터"
echo "- 대상 확장자: \`$ALLOW_EXT\`"
echo "- 규칙 파일: \`$(basename "$RULES_FILE")\` (${#RULES[@]} 개 규칙, 제외 ${EXCLUDED} 건)"
echo
echo "## 모듈별 수집 결과"
echo
echo "| 모듈 | 파일 수 | base 커밋 |"
echo "|---|---:|---|"
while IFS='|' read -r m n d; do
[ -z "$m" ] && continue
echo "| $m | $n | $d |"
done < "$TMP_SUMMARY"
echo
echo "**합계: ${TOTAL} 개**"
[ "$MISSING" -gt 0 ] && echo "> 추가 후 삭제/이동되어 현재 트리에 없는 파일 ${MISSING} 개는 제외됨."
echo
echo "## 파일 목록"
echo
echo "\`MANIFEST.csv\` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자)."
} > "$SUMMARY"
echo
echo "MANIFEST : $MANIFEST"
echo "SUMMARY : $SUMMARY"
if [ "$MAKE_ZIP" -eq 1 ]; then
ZIP_NAME="djb-custom-export-$(date '+%Y%m%d').zip"
ZIP_PATH="$(dirname "$OUT_DIR")/$ZIP_NAME"
rm -f "$ZIP_PATH"
( cd "$OUT_DIR" && zip -qr "$ZIP_PATH" . )
echo "ZIP : $ZIP_PATH"
fi
fi
@@ -0,0 +1,74 @@
# DJB 커스텀 소스 납품 - 포함/제외 규칙
#
# .gitignore 와 비슷하게 동작한다.
# - 한 줄에 패턴 하나. '#' 주석, 빈 줄 무시.
# - 기본은 "모두 포함". 패턴에 걸리면 제외된다.
# - '!' 로 시작하면 예외(다시 포함).
# - 뒤에 오는 규칙이 앞 규칙을 덮는다 (마지막 매칭 승리). → 순서가 중요하다.
# - 패턴에 '/' 가 있으면 <모듈>/<경로> 전체와 glob 매칭,
# 없으면 파일명(basename)과만 매칭한다.
# - '/' 로 끝나면 그 디렉터리 하위 전체를 뜻한다.
# - '*' 는 '/' 도 포함해 매칭한다(단순 glob).
#
# 매칭 대상 경로 예:
# eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/WebhookService.java
# eapim-admin/WebContent/jsp/onl/apim/webhook/webhookList.jsp
#
# 확인: ./export-custom.sh --dry-run (남는 목록)
# ./export-custom.sh --dry-run --show-excluded (규칙에 걸려 빠진 목록)
# ===========================================================================
# 1) 제품 업그레이드로 유입된 신규 파일 — 커스텀 아니므로 제외
# filelist-no-djb-marker.txt(djb/custom 마커 없는 신규 파일)를 검토하며 채운다.
# ===========================================================================
# 예시)
# eapim-online/src/main/java/com/eactive/eai/agent/inflow/
# eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/apis/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/app/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/
eapim-portal/src/main/java/com/eactive/apim/portal/config/
eapim-portal/src/main/java/com/eactive/apim/portal/common/
eapim-portal/src/main/resources/templates/views/fragment/popup/
eapim-portal/src/test/
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/
# ===========================================================================
# 2) 위 제외에 걸렸더라도 되살릴 예외
# djb 마커가 붙은 파일은 어떤 경우에도 커스텀이다.
# ※ 아래 3)의 빌드 산출물 제외보다 반드시 앞에 와야 한다.
# ===========================================================================
!*djb*
!*DJB*
!*Djb*
# ===========================================================================
# 3) 무조건 제외 (최종 승리) — 소스가 아니거나 서드파티
# ===========================================================================
*/build/
*/out/
*/bin/
*/.gradle/
*/.metadata/
*/node_modules/
*/generated/
*/design-backup/
*/WebContent/plugins/
# SafeDB(damo) 벤더 API stub — 컴파일용 껍데기라 납품 대상 아님
damo-manager/src/stub/
# 서드파티 폴더 안에 섞여 있는 우리 커스텀 파일은 되살린다
# (예: WebContent/plugins/swaggerUI/djb-swagger-i18n.js)
!*/WebContent/plugins/*djb*
@@ -0,0 +1,157 @@
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDay.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDayId.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHour.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHourId.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonth.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonthId.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwApiStatus.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwAuthClient.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwSvcInfo.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsDayRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsHourRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsMonthRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwApiStatusRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwAuthClientRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwSvcInfoRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/app/service/AdminGatewayClient.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorChannel.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorInfoResponse.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorSendResponse.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorVerifyResponse.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpAuthInterceptor.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpPasswordController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpProtectedPaths.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorCleanupScheduler.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorContext.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorService.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/community/notice/dto/IncidentAffectedApiDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/community/partnership/dto/PartnershipApplicationSummaryDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/constants/LoginFailureReason.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/constants/LoginType.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/controller/DuplicateLoginController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/service/DuplicateLoginService.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/ReadinessController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/entity/UserSession.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/filter/SessionValidationFilter.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/repository/UserSessionRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsPeriodDto.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/repository/entity/JobInfo.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/repository/JobInfoRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/user/service/UserRoleHistoryService.java
eapim-portal/src/main/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolver.java
eapim-portal/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java
eapim-portal/src/main/java/com/eactive/apim/portal/common/security/ClientGuardService.java
eapim-portal/src/main/java/com/eactive/apim/portal/common/security/LoginLockPolicy.java
eapim-portal/src/main/java/com/eactive/apim/portal/config/PasswordChangeEnforcementInterceptor.java
eapim-portal/src/main/java/com/eactive/apim/portal/config/PasswordEnforcementPolicy.java
eapim-portal/src/main/java/com/eactive/apim/portal/config/PortalPropertyDuplicateChecker.java
eapim-portal/src/main/java/com/eactive/apim/portal/config/StartupInfoPrinter.java
eapim-portal/src/main/resources/menu.yml
eapim-portal/src/main/resources/roles.yml
eapim-portal/src/main/resources/static/js/api-selector.js
eapim-portal/src/main/resources/static/js/password-policy.js
eapim-portal/src/main/resources/static/js/popup/two-factor-auth.js
eapim-portal/src/main/resources/static/sass/components/_board-common.scss
eapim-portal/src/main/resources/static/sass/components/_password-policy.scss
eapim-portal/src/main/resources/static/sass/components/_session-timer.scss
eapim-portal/src/main/resources/static/sass/components/_toast.scss
eapim-portal/src/main/resources/static/sass/components/_two-factor.scss
eapim-portal/src/main/resources/static/sass/pages/_api-status.scss
eapim-portal/src/main/resources/static/sass/pages/_webhook.scss
eapim-portal/src/main/resources/templates/views/apps/auth/stepupPassword.html
eapim-portal/src/main/resources/templates/views/apps/auth/twoFactorChallenge.html
eapim-portal/src/main/resources/templates/views/apps/community/mainFaqDetail.html
eapim-portal/src/main/resources/templates/views/apps/register/signupVerificationEmail.html
eapim-portal/src/main/resources/templates/views/apps/service/oauth2-guide.html
eapim-portal/src/main/resources/templates/views/apps/service/webhook-dev-guide.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookEmpty.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookList.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep2.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep3.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep2.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep3.html
eapim-portal/src/main/resources/templates/views/fragment/api_selector.html
eapim-portal/src/main/resources/templates/views/fragment/popup/cancelInvitationPopup.html
eapim-portal/src/main/resources/templates/views/fragment/popup/terminateRequestPopup.html
eapim-portal/src/main/resources/templates/views/fragment/popup/twoFactorAuthPopup.html
eapim-portal/src/test/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolverTest.java
eapim-portal/src/test/java/com/eactive/apim/portal/common/util/PhoneNumberUtilTest.java
eapim-portal/src/test/java/com/eactive/apim/portal/common/util/StringMaskingUtilTest.java
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapController.java
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapService.java
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapServiceImpl.java
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/ui/SitemapNode.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestEmsRepository.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestService.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestUISearch.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/portalinquiry/PortalInquiryCommentRepository.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/portalinquiry/PortalInquiryCommentService.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigDataService.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigDataServiceImpl.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigRepository.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/authserver/ClientBlockApiController.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/masking/MessagePatternMaskingUtils.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManController.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManService.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryClosingService.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryCommentUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryCommentUIMapper.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManController.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManService.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUIMapper.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentAffectedApiUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentTimelineUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigManController.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigManService.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigUiMapper.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/inflow/inflow/InflowClientControlManController.java
eapim-admin/src/main/resources/apistatus-draft.yml
eapim-admin/src/main/resources/logback-rinjae_ma.xml
eapim-admin/src/test/java/com/eactive/eai/rms/onl/apim/masking/MaskingUtilsTest.java
eapim-admin/src/test/java/com/eactive/eai/rms/onl/apim/masking/MessagePatternMaskingUtilsTest.java
eapim-admin/WebContent/damo.jsp
eapim-admin/WebContent/jsp/common/acl/sitemap/sitemapMan.jsp
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowClientControlMan.jsp
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowClientControlManDetail.jsp
eapim-admin/WebContent/jsp/onl/admin/rule/transform2/transform2ManApiPopup.jsp
eapim-admin/WebContent/jsp/onl/admin/security/cryptoModuleMan.jsp
eapim-admin/WebContent/jsp/onl/admin/security/cryptoModuleManDetail.jsp
eapim-admin/WebContent/jsp/onl/apim/messagerequest/messageRequestMan.jsp
eapim-admin/WebContent/jsp/onl/apim/messagerequest/messageRequestManDetail.jsp
eapim-admin/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookSendLogMan.jsp
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookSendLogManDetail.jsp
eapim-admin/WebContent/jsp/onl/kjb/statistics/apiUseStatsMan.jsp
elink-portal-common/src/main/java/com/eactive/apim/portal/apprequest/entity/GwAction.java
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/IpAddressMatcher.java
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/PhoneNumberUtil.java
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/PersonalDataEncryptConverter.java
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/UuidV7Generator.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalMenuItem.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalMenuPlacement.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRole.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRoleAuthority.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRoleAuthorityId.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalMenuItemRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalMenuPlacementRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalRoleAuthorityRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalRoleRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/service/PortalMenuDataService.java
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/entity/UserRoleHistory.java
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/repository/UserRoleHistoryRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/InquiryComment.java
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/VisibilityScope.java
elink-portal-common/src/test/java/com/eactive/apim/portal/common/util/IpAddressMatcherTest.java
+173
View File
@@ -0,0 +1,173 @@
eapim-portal/src/main/java/com/eactive/apim/portal/custom/config/DjbPasswordEncoder.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/controller/ApiStatusController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ActiveIncidentDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/AffectedApiDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiCurrentStatusDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiOptionDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/DailyStatDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/IssueDateEntryDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/MaintenanceCardDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/MyApiStatusDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/PastIssueCardDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/TimelineEntryDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusAssembler.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusSupport.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/MyApiStatusQueryService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/controller/InquiryCommentController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/dto/InquiryCommentCreateRequest.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/dto/InquiryCommentDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/UserInfoRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacade.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacadeImpl.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/constant/DjbInquiryStatus.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/exception/InquiryClosedException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/exception/InquiryCommentNotOwnedException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/support/InquiryCommentPermissionChecker.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/footer/RelatedSite.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/footer/RelatedSiteService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuAccessInterceptor.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuInternalController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuModelAdvice.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuNode.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuSeeder.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuView.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/PortalMenuYmlProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/PortalRolesProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/EmployeeIdPolicy.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingMessageWriter.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifier.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifyProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/config/SwingAsyncConfig.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/repository/SwingStaffRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/advice/DjbTestbedExceptionHandler.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedAuthController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialOptionDto.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialSecretDto.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbTestbedContextDto.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbGatewayMode.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/exception/DjbUnsupportedAuthTypeException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedAuthService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookCreatedResult.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookEventTypeDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/exception/WebhookAlreadyExistsException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/exception/WebhookNotFoundException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/mapper/WebhookMapper.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestApiRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestEventRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequest.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestApi.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestApiId.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestEvent.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestEventId.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookEventTypeProvider.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookSecretGenerator.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookService.java
eapim-portal/src/main/resources/menu.yml
eapim-portal/src/main/resources/roles.yml
eapim-portal/src/main/resources/static/favicon.png
eapim-portal/src/main/resources/static/img/avatar1.svg
eapim-portal/src/main/resources/static/img/avatar2.svg
eapim-portal/src/main/resources/static/img/favicon/apple-touch-icon.png
eapim-portal/src/main/resources/static/img/favicon/favicon-16x16.png
eapim-portal/src/main/resources/static/img/favicon/favicon-180x180.png
eapim-portal/src/main/resources/static/img/favicon/favicon-192x192.png
eapim-portal/src/main/resources/static/img/favicon/favicon-32x32.png
eapim-portal/src/main/resources/static/img/favicon/favicon-48x48.png
eapim-portal/src/main/resources/static/img/favicon/favicon-512x512.png
eapim-portal/src/main/resources/static/img/favicon/favicon.png
eapim-portal/src/main/resources/static/img/icon/icon_check_green.png
eapim-portal/src/main/resources/static/img/icon/img_icon.png
eapim-portal/src/main/resources/static/img/keyimage/api_img.png
eapim-portal/src/main/resources/static/img/keyimage/api_img.svg
eapim-portal/src/main/resources/static/img/keyimage/faq_img.png
eapim-portal/src/main/resources/static/img/keyimage/faq_img.svg
eapim-portal/src/main/resources/static/img/keyimage/feedback_img.svg
eapim-portal/src/main/resources/static/img/keyimage/notice_img.png
eapim-portal/src/main/resources/static/img/keyimage/notice_img.svg
eapim-portal/src/main/resources/static/img/keyimage/q&a_img.png
eapim-portal/src/main/resources/static/img/keyimage/q&a_img.svg
eapim-portal/src/main/resources/static/img/keyimage/sigdnUp_img.svg
eapim-portal/src/main/resources/static/img/keyimage/signUp_img.png
eapim-portal/src/main/resources/static/img/logo/logo-djb.png
eapim-portal/src/main/resources/static/img/logo/logo-jjb.png
eapim-portal/src/main/resources/static/img/logo/logo-jjb_white.png
eapim-portal/src/main/resources/static/js/api-selector.js
eapim-portal/src/main/resources/static/js/djb/api-status-issues.js
eapim-portal/src/main/resources/static/js/djb/api-status.js
eapim-portal/src/main/resources/static/js/djb/client-guard.js
eapim-portal/src/main/resources/static/js/djb/inquiry-comments.js
eapim-portal/src/main/resources/static/js/djb/inquiry-view.js
eapim-portal/src/main/resources/static/js/djb/toast.js
eapim-portal/src/main/resources/static/js/password-policy.js
eapim-portal/src/main/resources/static/js/popup/two-factor-auth.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-i18n.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippet-panel.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippets.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-testbed.css
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-validator.js
eapim-portal/src/main/resources/static/sass/base/_djb-font.scss
eapim-portal/src/main/resources/static/sass/components/_board-common.scss
eapim-portal/src/main/resources/static/sass/components/_djb-inquiry-comments.scss
eapim-portal/src/main/resources/static/sass/components/_password-policy.scss
eapim-portal/src/main/resources/static/sass/components/_session-timer.scss
eapim-portal/src/main/resources/static/sass/components/_toast.scss
eapim-portal/src/main/resources/static/sass/components/_two-factor.scss
eapim-portal/src/main/resources/static/sass/pages/_api-status.scss
eapim-portal/src/main/resources/static/sass/pages/_webhook.scss
eapim-portal/src/main/resources/templates/views/apps/auth/stepupPassword.html
eapim-portal/src/main/resources/templates/views/apps/auth/twoFactorChallenge.html
eapim-portal/src/main/resources/templates/views/apps/community/djb/fragments-inquiry-comments.html
eapim-portal/src/main/resources/templates/views/apps/community/mainFaqDetail.html
eapim-portal/src/main/resources/templates/views/apps/register/signupVerificationEmail.html
eapim-portal/src/main/resources/templates/views/apps/service/oauth2-guide.html
eapim-portal/src/main/resources/templates/views/apps/service/webhook-dev-guide.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookEmpty.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookList.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep2.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep3.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep2.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep3.html
eapim-portal/src/main/resources/templates/views/djb/apistatus/index.html
eapim-portal/src/main/resources/templates/views/djb/apistatus/issues.html
eapim-portal/src/main/resources/templates/views/fragment/api_selector.html
eapim-portal/src/main/resources/templates/views/fragment/djbank/footer.html
eapim-portal/src/main/resources/templates/views/fragment/djbank/header_container.html
eapim-portal/src/main/resources/templates/views/fragment/djbank/service_sidebar.html
eapim-portal/src/main/resources/templates/views/fragment/djbank/terms_agreements.html
elink-portal-common/src/main/java/com/eactive/apim/portal/apprequest/entity/GwAction.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncident.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentApi.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentApiId.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentTimeline.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/IncidentKind.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/IncidentState.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentApiRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentTimelineRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/entity/UserRoleHistory.java
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/repository/UserRoleHistoryRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/InquiryComment.java
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/VisibilityScope.java
damo-manager/src/main/java/com/eactive/ext/djb/DamoCli.java
damo-manager/src/main/java/com/eactive/ext/djb/DamoManager.java
+26
View File
@@ -0,0 +1,26 @@
# DJB 커스텀 소스 납품 수집 설정
#
# 형식:
# MODULE|<모듈 디렉터리>|<소스 경로들, 공백 구분>
# EXT|<수집 대상 확장자, 콤마 구분>
#
# - 모듈 디렉터리는 저장소 루트(djb-eapim) 기준 상대 경로.
# - 소스 경로가 실제로 없으면 조용히 건너뛴다.
# - '#'으로 시작하는 줄과 빈 줄은 무시된다.
# - 파일 단위 포함/제외는 export-filter.rules 에서 관리한다.
MODULE|eapim-portal|src/main
#MODULE|eapim-admin|src WebContent
MODULE|elink-portal-common|src/main
MODULE|damo-manager|src
# eapim-online 은 납품 범위에서 제외한다.
# Gateway 제품(elink-online) 본체라 신규 파일 중 무엇이 DJB 커스텀이고
# 무엇이 제품 업그레이드분인지 포털 쪽에서 판정할 수 없다.
# 규칙 파일이 아니라 여기서 빼는 이유: export-filter.rules 의 '!*djb*' 예외가
# 뒤에 있어 DJBEncrypt.java 같은 파일이 되살아나기 때문.
#MODULE|eapim-online|src elink-online-common/src elink-online-core/src elink-online-core-jpa/src elink-online-emsclient/src elink-online-transformer/src
# 수집 대상 확장자 (확장자 없는 파일은 제외된다)
# EXT|java,xml,yml,yaml,properties,sql,html,jsp,jspf,tag,js,ts,css,scss,json,txt,md
EXT|java,xml,yml,yaml,properties,html,jsp,jspf,tag,js,ts,css,scss,jpg,jpeg,png,gif,svg,ico
+2 -1
View File
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
)
;
create table DVPOWN.PT_TOKEN
create table PT_TOKEN
(
(
TOKEN VARCHAR2(255) not null
primary key,
+69
View File
@@ -0,0 +1,69 @@
# SonarQube 분석 설정 (SonarScanner CLI 용)
#
# Jenkinsfile.sonar 가 이 파일을 그대로 읽고, 아래 항목만 실행 시점에 -D 로 덮어쓴다.
# sonar.projectVersion : 빌드 번호
# sonar.java.libraries : ci/sonar-classpath.gradle 이 덤프한 컴파일 클래스패스
# sonar.java.test.libraries : 위 + 테스트 클래스패스
# sonar.junit.reportPaths : 테스트를 실행한 경우에만
#
# 로컬에서 수동 분석 시(스캐너는 JDK 17 필요):
# gradle clean classes testClasses
# gradle -I ci/sonar-classpath.gradle exportSonarClasspath
# sonar-scanner -Dsonar.host.url=... -Dsonar.token=... \
# -Dsonar.java.libraries="$(cat build/sonar/java-libraries.txt)"
sonar.projectKey=djb-eapim-portal
sonar.projectName=DJB eAPIM Portal
sonar.sourceEncoding=UTF-8
# --- 분석 대상 -------------------------------------------------------------
# src/main/resources 를 포함시켜 Thymeleaf 템플릿(XSS), yml/properties(하드코딩 시크릿)까지 검사한다.
sonar.sources=src/main/java,src/main/resources
sonar.tests=src/test/java
# --- Java ------------------------------------------------------------------
# 소스는 Java 8. 스캐너 실행 JRE 는 별개로 17 이상이어야 한다(Jenkinsfile.sonar 참고).
sonar.java.source=8
sonar.java.binaries=build/classes/java/main
sonar.java.test.binaries=build/classes/java/test
# --- 제외 대상 -------------------------------------------------------------
# 서드파티 번들/컴파일 산출물/바이너리. 분석해도 우리가 고칠 수 없는 코드는 뺀다.
sonar.exclusions=\
**/build/**,\
**/node_modules/**,\
**/lib/**,\
**/libs/**,\
**/local_jars/**,\
src/main/resources/static/plugins/**,\
src/main/resources/static/js/egovframework/**,\
src/main/resources/static/js/**/*.min.js,\
src/main/resources/static/js/lodash.js,\
src/main/resources/static/js/popper.js,\
src/main/resources/static/js/daterangepicker.js,\
src/main/resources/static/js/json-lint.js,\
src/main/resources/static/html/**,\
src/main/resources/static/css/**,\
src/main/resources/static/sass/**,\
src/main/resources/static/font/**,\
src/main/resources/static/webfonts/**,\
src/main/resources/static/img/**
# 자동 생성 코드(MapStruct Impl, QueryDSL Q클래스)는 build/ 아래라 이미 제외된다.
# Thymeleaf 인라인 표현식([[${...}]], /*[[${...}]]*/)은 JS 파서가 읽지 못해
# "Failed to parse file ... Unexpected token" 으로 분석이 중단된다.
# JS 분석에서만 빼고 HTML(web) 센서는 그대로 두어 템플릿 XSS 룰은 유지한다.
# 기본값이 **/node_modules/** 이므로 재정의 시 함께 명시해야 한다.
sonar.javascript.exclusions=\
**/node_modules/**,\
src/main/resources/templates/**
# --- SCM -------------------------------------------------------------------
# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다.
sonar.scm.provider=git
# --- 브랜치 ----------------------------------------------------------------
# sonar.branch.name 은 SonarQube Developer Edition 이상에서만 동작한다.
# Community Edition 에서 지정하면 분석이 실패하므로 기본적으로 설정하지 않는다.
# sonar.branch.name=feats/ci-test
@@ -0,0 +1,36 @@
package com.eactive.apim.gateway.data.statistics.entity;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
*
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
*/
@Entity
@Table(name = "API_STATUS")
@Data
public class GwApiStatus {
/** EAI 서비스명 */
@Id
@Column(name = "EAISVCNAME", length = 30)
private String eaisvcname;
/** N 정상 / C 점검 / D 지연 / E 장애 */
@Column(name = "STATUS_CODE", length = 1)
private String statusCode;
@Column(name = "MODIFIED_BY", length = 20)
private String modifiedBy;
@Column(name = "MODIFIED_DATE")
private LocalDateTime modifiedDate;
}
@@ -0,0 +1,17 @@
package com.eactive.apim.gateway.data.statistics.repository;
import com.eactive.apim.gateway.data.statistics.entity.GwApiStatus;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.time.LocalDateTime;
import java.util.Optional;
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
/**
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
*/
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
Optional<LocalDateTime> findLastModifiedDate();
}
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
/**
* 다건 org 소속 CLIENTID 목록 (인덱스 페이지 전체 통계 집계용).
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId IN :orgIds")
List<String> findClientIdsByOrgIdIn(@Param("orgIds") List<String> orgIds);
}
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
public PortalApplication() {
super();
}
public static void main(String[] args) {
portal_logger.info("##### PortalApplication Start #####");
@@ -5,19 +5,36 @@ import java.sql.SQLException;
import javax.sql.DataSource;
import org.jboss.logging.Logger;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.spring5.SpringTemplateEngine;
import com.google.common.util.concurrent.Futures;
/**
* Readiness probe.
*
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
* This endpoint validates both EMS and Gateway datasources via JDBC
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
* requests that depend on the database.
* {@code Connection.isValid(timeout)}, plus three libraries that WebLogic's
* oracle_common bundles at a different version from this app (see weblogic.xml
* {@code prefer-application-packages}): Thymeleaf's {@link SpringTemplateEngine}
* (jackson), jboss-logging, and Guava.
*
* <p>These exist because this controller bypasses view resolution entirely
* (plain {@code @RestController} JSON) — a classpath split between the app's
* copy and WebLogic's bundled copy of a preferred package can throw
* {@link LinkageError}/{@link java.util.ServiceConfigurationError} only on the
* real code paths that touch it, while this probe kept reporting 200. That
* already happened once for jackson (Thymeleaf's dialect/module discovery
* failed on every real page while /health/ready stayed green) — jboss-logging
* and Guava carry the same risk (confirmed version mismatch against
* oracle_common, not yet observed failing in production) so they get the same
* kind of forced-touch check here.
*
* <p>HTTP 200 + JSON when all checks pass.
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
@@ -30,24 +47,34 @@ public class ReadinessController {
private final DataSource portalDataSource;
private final DataSource gatewayDataSource;
private final SpringTemplateEngine templateEngine;
public ReadinessController(
@Qualifier("portalDataSource") DataSource portalDataSource,
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
@Qualifier("gatewayDataSource") DataSource gatewayDataSource,
SpringTemplateEngine templateEngine) {
this.portalDataSource = portalDataSource;
this.gatewayDataSource = gatewayDataSource;
this.templateEngine = templateEngine;
}
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> ready() {
String ems = check(portalDataSource);
String gw = check(gatewayDataSource);
boolean ok = "UP".equals(ems) && "UP".equals(gw);
String ems = check(portalDataSource);
String gw = check(gatewayDataSource);
String view = checkTemplateEngine();
String logging = checkJbossLogging();
String guava = checkGuava();
boolean ok = "UP".equals(ems) && "UP".equals(gw) && "UP".equals(view)
&& "UP".equals(logging) && "UP".equals(guava);
String body = "{"
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\","
+ "\"gateway\":\""+ gw + "\""
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\","
+ "\"gateway\":\"" + gw + "\","
+ "\"view\":\"" + view + "\","
+ "\"logging\":\"" + logging + "\","
+ "\"guava\":\"" + guava + "\""
+ "}";
return ResponseEntity.status(ok ? 200 : 503)
@@ -55,6 +82,49 @@ public class ReadinessController {
.body(body);
}
/** {@code getConfiguration()} lazily runs Thymeleaf's dialect/module init on first call
* and caches it — so this is cheap once healthy, and reproduces the exact failure path
* a real page render would hit. Catches {@link Throwable}: init failures here have
* surfaced as {@link Error} (ServiceConfigurationError), not just Exception. */
private String checkTemplateEngine() {
try {
templateEngine.getConfiguration();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
/** Forces jboss-logging's {@code Logger} + its bound provider (LogManager/JBoss/JUL
* discovery) to load and actually run a log-level check, not just resolve the class. */
private static String checkJbossLogging() {
try {
Logger log = Logger.getLogger(ReadinessController.class);
log.isDebugEnabled();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
/** {@code Futures.immediateFuture} touches Guava's {@code util.concurrent} machinery
* (AbstractFuture/InternalFutureFailureAccess) — the part backed by the separate
* {@code failureaccess} jar, which is exactly where an app/WebLogic version mismatch
* would split across classloaders. */
private static String checkGuava() {
try {
Futures.immediateFuture(Boolean.TRUE).isDone();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
private static String down(Throwable t) {
String msg = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
}
private static String check(DataSource ds) {
if (ds == null) return "DOWN:NO_DATASOURCE";
try (Connection c = ds.getConnection()) {
@@ -8,11 +8,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
@@ -30,6 +36,7 @@ public class ApiController {
private final ApiService apiService;
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -38,7 +45,24 @@ public class ApiController {
if (id == null) {
return "redirect:/apis/common";
}
populateDetailModel(id, model);
model.addAttribute("activeTab", "api-info");
return "apps/apis/mainApiDetail";
}
// 테스트베드를 API 정보와 별도 URL로 분리(딥링크·북마크 가능). 미인증 사용자도 페이지 진입은
// 허용하되, 실제 테스트베드(Swagger)는 인증 사용자에게만 렌더하고 미인증에는 로그인 안내를 노출한다.
@GetMapping("/detail/testbed")
public String apidetailTestbed(@RequestParam(value = "id", required = false) String id, ModelMap model) {
if (id == null) {
return "redirect:/apis/common";
}
populateDetailModel(id, model);
model.addAttribute("activeTab", "testbed");
return "apps/apis/mainApiDetail";
}
private void populateDetailModel(String id, ModelMap model) {
ApiSpecInfoDto api = apiService.selectDetail(id);
if (api == null) {
throw new NotFoundException(NOT_FOUND_MESSAGE);
@@ -46,28 +70,58 @@ public class ApiController {
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
// 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
if (apiGroup != null) {
api.setApiGroupName(apiGroup.getGroupName());
}
model.addAttribute("apiSpecInfo", api);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("services", searchResult.get("services"));
return "apps/apis/mainApiDetail";
model.addAttribute("authenticated", SecurityUtil.isAuthenticated());
}
@GetMapping
public String apiList(@ModelAttribute ApiGroupSearch search, Model model) {
public String apiList(@ModelAttribute ApiGroupSearch search, @PageableDefault Pageable pageable, Model model) {
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services"));
model.addAttribute("apis", searchResult.get("apis"));
model.addAttribute("apis", apiPage.getContent());
model.addAttribute("page", apiPage);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
return "apps/apis/mainApiList";
}
/**
* apiSearchFacade.searchApis() 는 다른 소비처(API Status 필터 목록 등)와 계약을 공유하므로
* 항상 전체 List 를 돌려준다. 목록 화면 렌더링에서만 결과를 잘라 Page 로 감싼다.
*/
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
int total = apis == null ? 0 : apis.size();
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
}
@GetMapping("/testbed/api")
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?reason=auth";
}
String selectedApiServiceName = "API 서비스 선택";
String selectedServiceId = "";
boolean idExists = false;
@@ -93,6 +147,10 @@ public class ApiController {
@GetMapping("/testbed")
public String testbedByApiService(@RequestParam(value = "id", required = false) String id, Model model) {
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?reason=auth";
}
String selectedApiServiceName = "API 서비스 선택";
boolean idExists = false;
@@ -44,13 +44,14 @@ public class TestbedSpecController {
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
/** default 토큰 spec 또는 저장 spec(서버 → GW 주소 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
try {
// 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로라
// GW 호스트를 붙이면 실재하지 않는 주소가 된다(servers 없음 → 문서 origin 사용).
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("Failed to read default token api spec file", e);
return null;
@@ -61,6 +62,6 @@ public class TestbedSpecController {
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
return serverRewriter.rewriteServerToGateway(spec.get().getTestbedSpec(), request);
}
}
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
private String displayRoleCode;
private String apiGroupName;
private String apiGroupId;
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader;
import java.io.DataOutputStream;
@@ -11,6 +12,9 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -21,6 +25,57 @@ public class APISender {
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
/** 응답 charset 을 Content-Type 에서 얻지 못할 때 사용할 기본값. */
private static final String DEFAULT_CHARSET = "UTF-8";
/**
* 프록시 대상(GW/mock) 응답을 상태코드까지 포함해 전달하기 위한 홀더.
*
* <p>본문만 반환하면 대상이 4xx/5xx 를 내려도 호출측이 200 으로 되돌려주게 되므로
* 상태코드와 Content-Type 을 함께 담는다.</p>
*/
public static class ApiResponse {
private final int status;
private final String body;
private final String contentType;
private final Map<String, List<String>> headers;
public ApiResponse(int status, String body, String contentType, Map<String, List<String>> headers) {
this.status = status;
this.body = body;
this.contentType = contentType;
this.headers = headers == null
? Collections.<String, List<String>>emptyMap()
: Collections.unmodifiableMap(headers);
}
public int getStatus() {
return status;
}
public String getBody() {
return body;
}
/** 대상 응답의 Content-Type 원본 (없으면 null). */
public String getContentType() {
return contentType;
}
/** 대상 응답 헤더 (상태줄 의사헤더인 null 키는 제외). 값은 헤더당 복수 가능. */
public Map<String, List<String>> getHeaders() {
return headers;
}
@Override
public String toString() {
return "ApiResponse{status=" + status + ", contentType=" + contentType
+ ", bodyLen=" + (body == null ? 0 : body.length())
+ ", headers=" + headers.keySet() + "}";
}
}
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
private final DjbTestbedGatewayProperty gatewayProperty;
@@ -35,15 +90,20 @@ public class APISender {
connection.setReadTimeout(t);
}
public String requestPost(String uri, String requestBody) throws IOException {
public ApiResponse requestPost(String uri, String requestBody) throws IOException {
if (logger.isDebugEnabled()) {
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
}
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
String response = getResponse(connection);
ApiResponse response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender POST(json) 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
}
return response;
}
@@ -69,7 +129,7 @@ public class APISender {
return uriBuilder.toString();
}
public String requestGet(String uri, Map<String, String> headers, Map<String, String[]> params) throws IOException {
public ApiResponse requestGet(String uri, Map<String, String> headers, Map<String, String[]> params) throws IOException {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
@@ -85,16 +145,20 @@ public class APISender {
}
connection.setDoOutput(true);
String response = getResponse(connection);
if (logger.isDebugEnabled()) {
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
}
ApiResponse response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender GET 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
}
return response;
}
public String requestPost(String uri, Map<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
public ApiResponse requestPost(String uri, Map<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
@@ -110,36 +174,79 @@ public class APISender {
}
connection.setDoOutput(true);
if (logger.isDebugEnabled()) {
// client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
}
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
outputStream.write(requestBodyBytes);
outputStream.flush();
}
String response = getResponse(connection);
ApiResponse response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender POST 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
}
return response;
}
private static String getResponse(HttpURLConnection connection) throws IOException {
/**
* 대상 응답을 상태코드·Content-Type·본문으로 읽는다.
*
* <p>4xx/5xx 는 {@code getInputStream()} 이 IOException 을 던지므로 errorStream 으로 본문을 읽고,
* 본문이 아예 없는 응답(errorStream == null)은 빈 문자열로 처리한다.</p>
*/
private static ApiResponse getResponse(HttpURLConnection connection) throws IOException {
int responseCode = connection.getResponseCode();
String contentType = connection.getContentType();
StringBuilder response = new StringBuilder();
try (InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream(); InputStreamReader isr = new InputStreamReader(stream);
BufferedReader reader = new BufferedReader(isr)) {
InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream();
if (stream != null) {
try (InputStreamReader isr = new InputStreamReader(stream, charsetOf(contentType));
BufferedReader reader = new BufferedReader(isr)) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
}
return response.toString();
return new ApiResponse(responseCode, response.toString(), contentType, copyHeaders(connection));
}
/** 대상 응답 헤더 복사. {@code getHeaderFields()} 의 null 키(상태줄)는 제외. */
private static Map<String, List<String>> copyHeaders(HttpURLConnection connection) {
Map<String, List<String>> headers = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : connection.getHeaderFields().entrySet()) {
if (entry.getKey() != null) {
headers.put(entry.getKey(), entry.getValue());
}
}
return headers;
}
/** Content-Type 의 charset 파라미터를 파싱. 없거나 인식 불가면 UTF-8. */
private static String charsetOf(String contentType) {
if (contentType != null) {
for (String part : contentType.split(";")) {
String token = part.trim();
if (token.toLowerCase().startsWith("charset=")) {
String charset = token.substring("charset=".length()).replace("\"", "").trim();
if (!charset.isEmpty() && java.nio.charset.Charset.isSupported(charset)) {
return charset;
}
}
}
}
return DEFAULT_CHARSET;
}
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
@@ -0,0 +1,294 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
*
* <p>logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
* 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.</p>
*
* <p>마스킹 정책:</p>
* <ul>
* <li>secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹</li>
* <li>그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹</li>
* <li>JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹</li>
* </ul>
*/
public final class ApiTesterAuditLogger {
private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 값 전체를 마스킹할 키(소문자 비교) */
private static final Set<String> SECRET_KEYS = new HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
"api_key", "apikey", "access_token", "refresh_token", "authorization"));
/** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
private static final String[] AUDIT_HEADERS = {
"content-type", "accept", "referer", "origin", "x-forwarded-for",
"original-api-id", "authorization"};
/** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
private static final int JSON_LOG_MAX_LENGTH = 2000;
/** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
private static final int NON_JSON_PREVIEW_LENGTH = 8;
private ApiTesterAuditLogger() {
}
/** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
public static String newAuditId() {
return UUID.randomUUID().toString().substring(0, 8);
}
/**
* 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
*
* @param targetUrl original-url 헤더 값 (없으면 null)
* @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
* @param tokenRequest OAuth 토큰 발급 요청 여부
* @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
*/
public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
String gatewayMode, boolean tokenRequest, String body) {
try {
StringBuilder sb = new StringBuilder(256);
sb.append("REQ [").append(auditId).append(']');
sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
sb.append(" user=").append(currentUser());
sb.append(" method=").append(request.getMethod());
sb.append(" mode=").append(gatewayMode);
sb.append(" token=").append(tokenRequest);
sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
sb.append(" headers=").append(buildHeaderSummary(request));
sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
auditLogger.info(sb.toString());
} catch (Exception e) {
auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
}
}
/** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
public static void logResult(String auditId, int status, String type, long elapsedMillis) {
auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
}
// =========================================================================
// 요청 정보 구성
// =========================================================================
/** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
private static String currentUser() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
return "anonymous";
}
String name = auth.getName();
return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
} catch (Exception e) {
return "unknown";
}
}
/** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
private static String buildHeaderSummary(HttpServletRequest request) {
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (String name : AUDIT_HEADERS) {
String value = request.getHeader(name);
if (value == null) {
continue;
}
if (!first) {
sb.append(", ");
}
first = false;
sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
}
return sb.append('}').toString();
}
/** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
private static String maskHeaderValue(String name, String value) {
if (!SECRET_KEYS.contains(name.toLowerCase())) {
return value;
}
int space = value.indexOf(' ');
if (space > 0) {
return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
}
return partialMask(value);
}
// =========================================================================
// 본문 마스킹
// =========================================================================
private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
if (body == null || body.isEmpty()) {
return "-";
}
// 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
if (tokenRequest) {
return "\"" + maskFormBody(body) + "\"";
}
// 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
if (contentType != null && contentType.toLowerCase().contains("json")) {
String maskedJson = tryMaskJson(body);
if (maskedJson != null) {
return maskedJson;
}
}
return nonJsonSummary(body);
}
/** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
private static String maskFormBody(String body) {
StringBuilder sb = new StringBuilder(body.length());
String[] pairs = body.split("&");
for (int i = 0; i < pairs.length; i++) {
if (i > 0) {
sb.append('&');
}
int eq = pairs[i].indexOf('=');
if (eq < 0) {
sb.append(partialMask(pairs[i]));
continue;
}
String key = pairs[i].substring(0, eq);
String value = pairs[i].substring(eq + 1);
sb.append(key).append('=');
sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
}
return sanitize(sb.toString());
}
/** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
private static String maskQueryValues(String url) {
int qs = url.indexOf('?');
if (qs < 0) {
return url;
}
return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
}
/** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
private static String tryMaskJson(String body) {
try {
JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
String out = OBJECT_MAPPER.writeValueAsString(masked);
if (out.length() > JSON_LOG_MAX_LENGTH) {
out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
}
return out;
} catch (Exception e) {
return null;
}
}
/** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
private static JsonNode maskJsonNode(JsonNode node) {
if (node.isObject()) {
ObjectNode obj = (ObjectNode) node;
Iterator<String> names = obj.fieldNames();
Set<String> fieldNames = new HashSet<>();
while (names.hasNext()) {
fieldNames.add(names.next());
}
for (String field : fieldNames) {
if (SECRET_KEYS.contains(field.toLowerCase())) {
obj.set(field, TextNode.valueOf("*****"));
} else {
obj.set(field, maskJsonNode(obj.get(field)));
}
}
return obj;
}
if (node.isArray()) {
ArrayNode arr = (ArrayNode) node;
for (int i = 0; i < arr.size(); i++) {
arr.set(i, maskJsonNode(arr.get(i)));
}
return arr;
}
if (node.isNull() || node.isMissingNode()) {
return node;
}
return TextNode.valueOf(partialMask(node.asText()));
}
/** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
private static String nonJsonSummary(String body) {
int bytes = body.getBytes(StandardCharsets.UTF_8).length;
String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
}
// =========================================================================
// 공통 helper
// =========================================================================
/** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
private static String partialMask(String value) {
if (value == null || value.isEmpty()) {
return "";
}
int len = value.length();
if (len <= 2) {
return stars(len);
}
int visible = Math.min(4, Math.max(1, len / 3));
return value.substring(0, visible) + "***";
}
private static String stars(int count) {
char[] arr = new char[count];
Arrays.fill(arr, '*');
return new String(arr);
}
/** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
private static String sanitize(String value) {
if (value == null) {
return "-";
}
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '"') {
sb.append('\'');
} else if (c == '\r' || c == '\n' || c == '\t') {
sb.append(' ');
} else if (c < 0x20) {
sb.append('?');
} else {
sb.append(c);
}
}
return sb.toString();
}
}
@@ -4,15 +4,20 @@ package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.io.BufferedReader;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
@@ -31,6 +36,13 @@ public class ApiTesterFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(ApiTesterFilter.class);
/** 대상 응답에서 클라이언트로 되돌리지 않는 헤더 (소문자 비교). */
private static final Set<String> BLOCKED_RESPONSE_HEADERS = new HashSet<>(Arrays.asList(
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade",
"content-length", "content-encoding", "content-type",
"set-cookie", "set-cookie2"));
@Override
public void init(FilterConfig filterConfig) throws ServletException {
logger.debug("ApiTesterFilter initialized");
@@ -68,8 +80,18 @@ public class ApiTesterFilter implements Filter {
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
String auditId = ApiTesterAuditLogger.newAuditId();
long auditStart = System.currentTimeMillis();
String auditType = "-";
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
String proxyTarget = null;
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
System.currentTimeMillis() - auditStart);
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
return;
}
@@ -78,23 +100,24 @@ public class ApiTesterFilter implements Filter {
// 반환하기 위해 try 로 감싼다.
try {
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
// 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
// - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
// - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath());
boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
if (tokenRequest) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
String body = requestBody;
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
if (mockTokenRequest) {
auditType = "TOKEN_MOCK";
// mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
@@ -106,7 +129,7 @@ public class ApiTesterFilter implements Filter {
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" +
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
@@ -117,22 +140,32 @@ public class ApiTesterFilter implements Filter {
response.getWriter().println(token);
} else {
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
auditType = "TOKEN_GW";
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/x-www-form-urlencoded");
headers.put("Accept", "application/json");
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
proxyTarget = target;
if (logger.isDebugEnabled()) {
// client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
}
APISender.ApiResponse tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
response.setContentType("application/json");
response.getWriter().println(tokenResponse);
writeUpstream(response, tokenResponse);
}
} else {
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
// 스펙 조회는 original-api-id 헤더(Swagger UI 가 x-original-api-id 확장에서 전달) 우선.
// mock/gw 응답유형은 서버주소가 mockUrl·GW base 로 치환돼 original-url 의 path 가 저장된
// api_url 과 일치하지 않으므로 URL 매칭만으로는 스펙을 찾지 못한다.
ApiSpecInfoDto apiSpecInfoDto = selectSpec(apiSpecInfoDtoService, httpServletRequest, url);
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) {
auditType = "SPEC_NOT_FOUND";
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return;
@@ -142,6 +175,7 @@ public class ApiTesterFilter implements Filter {
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
auditType = "SAMPLE";
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return;
@@ -151,6 +185,7 @@ public class ApiTesterFilter implements Filter {
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType);
auditType = gw ? "GW" : "MOCK";
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
@@ -160,15 +195,20 @@ public class ApiTesterFilter implements Filter {
}
headers.remove("original-url");
headers.remove("original-api-id");
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
String targetUri;
Map<String, String[]> paramMap;
if (gw) {
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
// original-url 은 브라우저가 spec 서버주소(대외)로 만든 값이라 포탈 서버에서 도달하지 못할
// 수 있다. path/query 만 떼어 대내 base-url 에 재조립한 주소로 forward 한다.
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
headers.remove("host");
headers.remove("Host");
targetUri = url;
targetUri = gatewayProperty.resolveGatewayCallUrl(url);
paramMap = new HashMap<>();
} else {
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
@@ -176,36 +216,65 @@ public class ApiTesterFilter implements Filter {
paramMap = extractQueryParams(url);
}
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
proxyTarget = targetUri;
if (logger.isDebugEnabled()) {
// GW SERVICE_NOT_FOUND(어댑터 URI 미등록)·AUTH_FAIL 진단용:
// 스펙 식별/응답유형, 실제 forward 대상, 전달 헤더(민감값 마스킹), 본문 길이를 남긴다.
logger.debug("{} forward - auditId={}, apiId={}, apiUrl={}, apiMethod={}, responseType={}, originalUrl={}, target={}, bodyLen={}, headers={}",
auditType, auditId, apiSpecInfoDto.getApiId(), apiSpecInfoDto.getApiUrl(),
apiSpecInfoDto.getApiMethod(), responseType, url, targetUri,
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
APISender.ApiResponse upstream;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
upstream = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
} else {
upstream = apiSender.requestGet(targetUri, headers, paramMap);
}
if (logger.isDebugEnabled()) {
logger.debug("{} response - auditId={}, target={}, status={}, respLen={}, preview={}",
auditType, auditId, targetUri, upstream.getStatus(),
upstream.getBody() == null ? 0 : upstream.getBody().length(), previewOf(upstream.getBody()));
}
writeUpstream(response, upstream);
}
} catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) {
// 연결 실패 등 네트워크 오류
logger.error("테스트베드 프록시 호출 실패", e);
// 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
} catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환
logger.error("테스트베드 프록시 처리 오류", e);
logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} finally {
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
System.currentTimeMillis() - auditStart);
}
}
/** 요청 본문 전체를 문자열로 읽는다. */
/**
* 요청 본문 전체를 문자열로 읽는다.
*
* <p>form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
* 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
* (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
* 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.</p>
*/
private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
@@ -213,9 +282,61 @@ public class ApiTesterFilter implements Filter {
while ((line = reader.readLine()) != null) {
sb.append(line);
}
if (sb.length() == 0 && isFormUrlEncoded(request)) {
String rebuilt = rebuildFormBodyFromParams(request);
if (!rebuilt.isEmpty()) {
logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
return rebuilt;
}
}
return sb.toString();
}
/** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
private boolean isFormUrlEncoded(HttpServletRequest request) {
String contentType = request.getContentType();
return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
}
/** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
private String rebuildFormBodyFromParams(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
for (String value : entry.getValue()) {
if (sb.length() > 0) {
sb.append('&');
}
try {
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
.append('=')
.append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
} catch (java.io.UnsupportedEncodingException e) {
sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
}
}
}
return sb.toString();
}
/**
* 호출 대상 API 명세를 찾는다. {@code original-api-id} 헤더가 있으면 API ID 로, 없으면 기존처럼
* {@code original-url} 의 path + 메서드로 조회한다. 없으면 null.
*
* <p>API ID 는 클라이언트가 보내는 값이므로 URL 조회와 동일하게 <b>포탈 게시(display_yn='Y')</b> 인
* 스펙만 허용한다 — 비공개 API 가 ID 추측으로 호출되지 않도록.</p>
*/
private ApiSpecInfoDto selectSpec(ApiService apiService, HttpServletRequest request, String url) {
String apiId = request.getHeader("original-api-id");
if (apiId != null && !apiId.trim().isEmpty()) {
ApiSpecInfoDto dto = apiService.selectDetail(apiId.trim());
if (dto != null && "Y".equalsIgnoreCase(dto.getDisplayYn())) {
return dto;
}
logger.debug("original-api-id 로 게시된 스펙을 찾지 못해 URL 매칭으로 폴백 - apiId={}", apiId);
}
return apiService.selectDetailByURLAndMethod(parseUri(url), request.getMethod());
}
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
private Map<String, String[]> extractQueryParams(String originalUrl) {
Map<String, String[]> paramMap = new HashMap<>();
@@ -231,7 +352,74 @@ public class ApiTesterFilter implements Filter {
return paramMap;
}
/** 상태코드 + JSON 본문 응답. */
/**
* 프록시 대상(GW/mock) 응답을 상태코드·헤더·본문 그대로 클라이언트에 전달한다.
*
* <p>대상이 404/400 을 내려도 200 으로 포장되지 않도록 상태코드를 그대로 세팅한다.
* 단 아래 헤더는 전달하지 않는다.</p>
* <ul>
* <li>hop-by-hop 헤더(connection/keep-alive/transfer-encoding 등) — 연결 단위 헤더라 재전송 대상 아님</li>
* <li>content-length / content-encoding — 본문을 문자열로 다시 쓰므로 원본 길이·압축 정보가 맞지 않음</li>
* <li>set-cookie — 대상 쿠키가 포탈 도메인에 심겨 세션 쿠키를 덮어쓸 수 있어 차단</li>
* </ul>
*/
private void writeUpstream(ServletResponse response, APISender.ApiResponse upstream) throws IOException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(upstream.getStatus());
for (Map.Entry<String, List<String>> entry : upstream.getHeaders().entrySet()) {
String name = entry.getKey();
if (isBlockedResponseHeader(name) || entry.getValue() == null) {
continue;
}
boolean first = true;
for (String value : entry.getValue()) {
if (value == null) {
continue;
}
if (first) {
httpResponse.setHeader(name, value);
first = false;
} else {
httpResponse.addHeader(name, value);
}
}
}
// Content-Type 은 응답 문자셋까지 결정하므로 헤더 복사와 별개로 마지막에 확정한다.
String contentType = upstream.getContentType();
response.setContentType(contentType == null || contentType.trim().isEmpty()
? "application/json" : contentType);
response.getWriter().println(upstream.getBody() == null ? "" : upstream.getBody());
}
/** 클라이언트로 되돌리면 안 되는 응답 헤더인지. */
private boolean isBlockedResponseHeader(String name) {
return name == null || BLOCKED_RESPONSE_HEADERS.contains(name.toLowerCase());
}
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
private String maskHeaders(Map<String, String> headers) {
StringBuilder sb = new StringBuilder("{");
for (Map.Entry<String, String> e : headers.entrySet()) {
if (sb.length() > 1) {
sb.append(", ");
}
sb.append(e.getKey()).append(':').append(StringMaskingUtil.maskHeaderValue(e.getKey(), e.getValue()));
}
return sb.append('}').toString();
}
/** 응답 body debug 프리뷰 — 앞 300자까지만 (개행 제거). */
private String previewOf(String body) {
if (body == null) {
return "null";
}
String flat = body.replaceAll("\\s+", " ").trim();
return flat.length() > 300 ? flat.substring(0, 300) + "" : flat;
}
private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json");
@@ -11,8 +11,14 @@ public interface ApiSpecMapper {
ApiSpecInfoDto mapToDto(ApiSpecInfo apiSpecInfo);
/**
* 조회 결과가 없으면 {@code null} 을 반환한다.
*
* <p>빈 DTO 를 반환하면 호출측의 "스펙 없음" 분기가 동작하지 않고 모든 필드가 null 인 DTO 로
* 진행돼(예: {@code responseType == null} → sample 취급) 오동작한다.</p>
*/
default ApiSpecInfoDto map(Optional<ApiSpecInfo> optionalApiSpecInfo) {
return optionalApiSpecInfo.map(this::mapToDto).orElse(new ApiSpecInfoDto());
return optionalApiSpecInfo.map(this::mapToDto).orElse(null);
}
}
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
filteredApis.forEach(api -> {
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
if (service != null) {
api.setMainIcon(service.getMainIcon());
// mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
api.setApiGroupId(service.getId());
api.setApiGroupName(service.getGroupName());
}
});
@@ -5,8 +5,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
@RequiredArgsConstructor
public class ApiServiceController {
// ApiGroup.mainIcon 은 "data:image/png;base64,...." 형태의 Data URL 문자열 그대로 저장돼 있다.
private static final Pattern DATA_URL_PATTERN = Pattern.compile("^data:(image/[a-zA-Z0-9+.-]+);base64,(.+)$", Pattern.DOTALL);
private static final String DEFAULT_ICON_PATH = "/img/api_icon_default.png";
private final ApiServiceService apiServiceService;
@@ -52,4 +65,28 @@ public class ApiServiceController {
return "apps/apiservice/apiServiceDetail";
}
/**
* API 그룹 아이콘 스트리밍. mainIcon(CLOB, Data URL 문자열)을 그대로 HTML에 인라인하면
* 카드 개수만큼 반복 전송돼 응답 용량이 폭증하므로, 그룹 id 당 1회만 내려주고
* 브라우저 캐시로 재사용시킨다.
*/
@GetMapping("/{id}/icon")
public ResponseEntity<byte[]> icon(@PathVariable String id) {
String mainIcon = apiServiceService.getMainIcon(id);
Matcher matcher = mainIcon == null ? null : DATA_URL_PATTERN.matcher(mainIcon);
if (matcher == null || !matcher.matches()) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, DEFAULT_ICON_PATH)
.build();
}
byte[] imageBytes = Base64.getDecoder().decode(matcher.group(2));
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(matcher.group(1)))
.cacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic())
.body(imageBytes);
}
}
@@ -22,8 +22,19 @@ import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* API 그룹(전시) 조회 서비스.
*
* <p>주 조회 대상 {@link ApiGroup} 은 <b>AGW(게이트웨이) 데이터소스</b> 엔티티라
* 트랜잭션 매니저를 {@code gatewayTransactionManager} 로 지정한다. 기본
* {@code transactionManager}(EMS) 를 쓰면 게이트웨이 EntityManager 가 리포지토리 호출 단위로
* 닫혀 {@code ApiGroup.apiGroupApiList} 지연 로딩에서 LazyInitializationException 이 난다.</p>
*
* <p>내부에서 호출하는 {@code ApiSpecInfoService} 는 EMS 쪽이며 자체 {@code @Transactional}
* (기본 매니저)로 독립 트랜잭션을 연다. {@code ApiSpecInfo} 에는 지연 연관이 없어 문제되지 않는다.</p>
*/
@Service("apiServiceService")
@Transactional
@Transactional("gatewayTransactionManager")
@RequiredArgsConstructor
@Slf4j
public class ApiServiceService {
@@ -109,5 +120,14 @@ public class ApiServiceService {
.orElse(null);
}
/**
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
*/
public String getMainIcon(String id) {
return apiServiceRepository.findById(id)
.map(ApiGroup::getMainIcon)
.orElse(null);
}
}
@@ -36,6 +36,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.stereotype.Service;
@Service
@@ -155,10 +156,10 @@ public class ApiSpecService {
updateReferences(pathItem, apiId);
String tmpFullPath = StringUtils.isNotEmpty(basePath)
? StringUtils.join(basePath, "/", StringUtils.removeStart(path, "/"))
? StringUtils.join(basePath, "/", Strings.CS.removeStart(path, "/"))
: path;
final String fullPath = StringUtils.replacePattern(tmpFullPath, "//+", "/");
final String fullPath = tmpFullPath.replaceAll("//+", "/");
// HTTP 메소드별로 중복 체크
pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
@@ -294,7 +295,7 @@ public class ApiSpecService {
return StringUtils.EMPTY;
}
Server server = api.getServers().get(0);
return StringUtils.removeEnd(server.getUrl(), "/");
return Strings.CS.removeEnd(server.getUrl(), "/");
}
private void mergeComponents(OpenAPI currentAPI, String apiId, String apiName, Map<String, Object> mergedComponents, ObjectMapper objectMapper) {
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.app.controller;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.approval.statemachine.InvalidApprovalTransitionException;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
@@ -10,8 +11,11 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
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.TwoFactorService;
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -19,6 +23,8 @@ import com.eactive.apim.portal.file.service.FileTypeDetector;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -48,7 +54,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Slf4j
@Controller
@RequestMapping("/myapikey")
@RequestMapping("/clients")
@RequiredArgsConstructor
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
public class MyAppController {
@@ -80,7 +86,8 @@ public class MyAppController {
private final ApiService apiService;
private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
@@ -97,8 +104,28 @@ public class MyAppController {
List<AppRequest> appRequests = appServiceFacade.getPendingApiKeyList(user.getPortalOrg());
List<ClientDTO> apikeys = appServiceFacade.getApikeyList(user.getPortalOrg());
model.addAttribute("appRequests", appRequests);
// 기존 클라이언트에 걸린 변경/해지 신청은 별도 카드로 내지 않고 해당 클라이언트 카드의 배지로 흡수한다.
// (같은 이름의 카드가 둘로 보여 클라이언트가 두 개인 것처럼 읽히기 때문)
// 신규(NEW) 신청처럼 아직 클라이언트가 없는 건만 신청 카드로 남긴다.
Set<String> ownedClientIds = apikeys.stream()
.map(ClientDTO::getClientid)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
Map<String, AppRequest> openRequestByClient = new java.util.HashMap<>();
List<AppRequest> standaloneRequests = new java.util.ArrayList<>();
for (AppRequest request : appRequests) {
String clientId = request.getClientId();
if (StringUtils.isNotBlank(clientId) && ownedClientIds.contains(clientId)
&& openRequestByClient.putIfAbsent(clientId, request) == null) {
continue;
}
standaloneRequests.add(request);
}
model.addAttribute("appRequests", standaloneRequests);
model.addAttribute("apiKeys", apikeys);
model.addAttribute("openRequestByClient", openRequestByClient);
return new ModelAndView(API_KEY_LIST);
}
@@ -112,14 +139,14 @@ public class MyAppController {
@Secured("ROLE_APP")
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
if (appRequest == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// API 목록 조회 및 설정
@@ -150,14 +177,14 @@ public class MyAppController {
@Secured("ROLE_APP")
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
if (apiKey == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// API 목록에 서비스 정보 추가
@@ -178,7 +205,11 @@ public class MyAppController {
model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("authType", "OAuth2");
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
// 해지 불가 사유(변경 신청 진행 중 등) — 클릭 시 사전검사가 이 사유를 안내하고 2FA 로 넘어가지 않는다
model.addAttribute("deleteBlockReason", appServiceFacade.resolveDeleteBlockReason(id));
// 걸려 있는 미완료 변경/해지 신청 — 상태 표시 + 취소 버튼용(결재 미개시 건도 포함)
model.addAttribute("openRequest", appServiceFacade.findOpenRequest(id));
return new ModelAndView(CREDENTIAL_DETAIL);
}
@@ -217,21 +248,61 @@ public class MyAppController {
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
result.put("success", true);
result.put("message", "신청이 취소되었습니다.");
} catch (Exception e) {
} catch (InvalidApprovalTransitionException e) {
result.put("success", false);
result.put("message", "신청 취소 중 오류가 발생했습니다: " + e.getMessage());
result.put("message", "내부 결재가 진행 중이라 신청 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.");
} catch (IllegalStateException e) {
log.error("API Key 신청 취소 중 GW 차단 실패. id={}", id, e);
result.put("success", false);
result.put("message", e.getMessage());
} catch (Exception e) {
log.error("API Key 신청 취소 실패. id={}", id, e);
result.put("success", false);
result.put("message", "신청 취소 중 오류가 발생했습니다.");
}
return result;
}
/**
* API Key를 삭제합니다.
* AJAX 요청을 지원하기 위해 @ResponseBody를 사용하여 JSON 응답 반환
* API 이용 해지를 신청합니다. (AppRequestType.DELETE 결재 신청 생성)
* 즉시 차단/삭제하지 않으며, eapim-admin 관리자 승인 시점에 GW 차단/삭제와
* PTL_CREDENTIAL 삭제가 실행됩니다. 승인 전까지 API는 정상 동작합니다.
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#APP_KEY_DELETE} 인터셉터 가드)가 담당합니다.
*
* @param requestData 요청 데이터 (clientId와 type 포함)
* @param requestData 요청 데이터 (clientId, reason)
* @return 성공/실패 결과를 담은 Map
*/
/**
* 해지 신청 사전검사. 진행 중인 변경/해지 결재가 있어 신청이 불가한지 알려줍니다.
*
* <p>step-up 2FA 가 걸린 {@code /clients/api_key_delete} <b>이전</b>에 호출해,
* 어차피 거절될 요청으로 2FA 를 반복 유도하지 않도록 한다(2FA 가드 대상 경로가 아니다).
* 최종 판정은 {@code createDeleteRequest} 가 다시 수행하므로 이 검사는 안내 목적이다.</p>
*/
@GetMapping("/credential/delete-precheck")
@Secured("ROLE_API_KEY_REQUEST")
@ResponseBody
public Map<String, Object> deleteApiKeyPrecheck(@RequestParam("clientId") String clientId) {
Map<String, Object> result = new java.util.HashMap<>();
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String orgId = user.getPortalOrg().getId();
// 소유권 확인 (다른 조직 인증키의 결재 진행 상태 노출 방지)
if (clientId == null || clientId.trim().isEmpty()
|| appServiceFacade.getApiKey(orgId, clientId) == null) {
result.put("blocked", true);
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
return result;
}
String blockReason = appServiceFacade.resolveDeleteBlockReason(clientId);
result.put("blocked", blockReason != null);
result.put("msg", blockReason);
return result;
}
@PostMapping("/api_key_delete")
@Secured("ROLE_API_KEY_REQUEST")
@ResponseBody
@@ -245,38 +316,44 @@ public class MyAppController {
return result;
}
String reason = requestData.get("reason");
if (reason == null || reason.trim().isEmpty()) {
result.put("success", false);
result.put("msg", "해지 사유를 입력해 주세요.");
return result;
}
if (reason.length() > 1000) {
result.put("success", false);
result.put("msg", "해지 사유는 1000자 이내로 입력해 주세요.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String orgId = user.getPortalOrg().getId();
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
// 1. 소유권 확인 (다른 조직의 인증키 해지 방지)
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
result.put("success", false);
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
return result;
}
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
// 2. 해지 신청 생성 + 결재 개시 (GW/credential 은 승인 시점에 admin 이 처리)
try {
adminGatewayClient.blockClient(clientId);
} catch (Exception e) {
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
appServiceFacade.createDeleteRequest(clientId, reason.trim(), user.getPortalOrg());
} catch (IllegalStateException e) {
result.put("success", false);
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
result.put("msg", e.getMessage());
return result;
}
// 3. GW 차단 성공 시에만 포털 credential 삭제
try {
appServiceFacade.deleteApp(orgId, clientId);
} catch (Exception e) {
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
log.error("API 이용 해지 신청 실패 - clientId={}", clientId, e);
result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
result.put("msg", UserErrorMessageResolver.resolveAsHtml(e));
return result;
}
result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다.");
result.put("msg", "해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제니다.");
return result;
}
@@ -310,10 +387,12 @@ public class MyAppController {
}
/**
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
* 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
*
* @param requestData clientId, password 포함
* @param requestData clientId 포함
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
*/
@PostMapping("/credential/reveal-secret")
@@ -323,7 +402,6 @@ public class MyAppController {
Map<String, Object> result = new java.util.HashMap<>();
String clientId = requestData.get("clientId");
String password = requestData.get("password");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
@@ -333,14 +411,7 @@ public class MyAppController {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// 1. 본인 확인 (비밀번호)
if (!appServiceFacade.verifyUserPassword(user, password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
// 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
try {
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
if (secret == null) {
@@ -422,7 +493,7 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST_VIEW")
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey/api_key_request/history");
return new ModelAndView("redirect:/clients/api_key_request/history");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -520,7 +591,7 @@ public class MyAppController {
registration.setIpWhitelistFromString(ipWhitelist);
}
return new ModelAndView("redirect:/myapikey/register/step2");
return new ModelAndView("redirect:/clients/register/step2");
} catch (Exception e) {
setupStepModel(model, 1);
@@ -585,7 +656,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
@@ -616,7 +687,7 @@ public class MyAppController {
}
// 1단계로 리다이렉트
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
/**
@@ -634,22 +705,16 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
// API 선택 검증
if (selectedApis == null || selectedApis.isEmpty()) {
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
return new ModelAndView("redirect:/myapikey/register/step2");
}
// 선택된 API를 세션에 저장
registration.setSelectedApis(selectedApis);
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
registration.setSelectedApis(selectedApis != null ? selectedApis : new ArrayList<>());
// 등록이 완료되었는지 최종 검증
if (!registration.isComplete()) {
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -666,12 +731,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete();
return new ModelAndView("redirect:/myapikey/register/step3");
return new ModelAndView("redirect:/clients/register/step3");
} catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
return new ModelAndView("redirect:/myapikey/register/step2");
return new ModelAndView("redirect:/clients/register/step2");
}
}
@@ -689,7 +754,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (registrationSuccess == null || !registrationSuccess) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 결과 페이지 표시용 속성 설정
@@ -708,7 +773,7 @@ public class MyAppController {
public String cancelRegistration(SessionStatus sessionStatus) {
// 등록과 관련된 세션 데이터 초기화
sessionStatus.setComplete();
return "redirect:/myapikey";
return "redirect:/clients";
}
/**
@@ -752,13 +817,15 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST")
public ModelAndView modifyStep1(
@RequestParam(value = "clientId", required = false) String clientId,
@RequestParam(value = "goto", required = false) String gotoStep,
@RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus,
Model model,
RedirectAttributes redirectAttributes) {
if (clientId == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -766,7 +833,7 @@ public class MyAppController {
// 기존 API Key 정보 조회
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
if (apiKey == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 새로운 수정 세션 시작시에만 초기화
@@ -808,6 +875,16 @@ public class MyAppController {
model.addAttribute("apiKeyModification", modification);
}
// API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
// → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
if ("apis".equals(gotoStep)) {
String redirectUrl = "redirect:/clients/modify/step2";
if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
redirectUrl += "?apiApplyToast=" + apiApplyToast;
}
return new ModelAndView(redirectUrl);
}
// Step 모델 설정
setupStepModel(model, 1);
model.addAttribute("userOrg", user.getPortalOrg());
@@ -859,7 +936,7 @@ public class MyAppController {
modification.setIpWhitelistFromString(ipWhitelist);
}
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
} catch (Exception e) {
setupStepModel(model, 1);
@@ -881,7 +958,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// 서비스 카테고리와 API 목록 가져오기
@@ -891,6 +968,8 @@ public class MyAppController {
setupStepModel(model, 2);
model.addAttribute("apiServices", apiServices);
model.addAttribute("modification", modification);
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
return new ModelAndView(API_KEY_MODIFY_STEP2);
}
@@ -912,7 +991,7 @@ public class MyAppController {
}
// 1단계로 리다이렉트
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
/**
@@ -925,18 +1004,19 @@ public class MyAppController {
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus,
HttpSession session,
RedirectAttributes redirectAttributes) {
// 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// API 선택 검증
if (selectedApis == null || selectedApis.isEmpty()) {
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
}
// 선택된 API를 세션에 저장
@@ -945,7 +1025,15 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증
if (!modification.isComplete()) {
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
// 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
if (isAppModifyTwofaRequired()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
return new ModelAndView("redirect:/clients/modify/step2");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -962,12 +1050,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete();
return new ModelAndView("redirect:/myapikey/modify/step3");
return new ModelAndView("redirect:/clients/modify/step3");
} catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
}
}
@@ -984,7 +1072,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (modificationComplete == null || !modificationComplete) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 결과 페이지 표시용 속성 설정
@@ -1013,12 +1101,18 @@ public class MyAppController {
// clientId가 있으면 상세 페이지로, 없으면 목록으로
if (clientId != null && !clientId.isEmpty()) {
return "redirect:/myapikey/credential_detail?id=" + clientId;
return "redirect:/clients/credential_detail?id=" + clientId;
} else {
return "redirect:/myapikey";
return "redirect:/clients";
}
}
/** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
private boolean isAppModifyTwofaRequired() {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
}
}
@@ -36,12 +36,12 @@ public class ApiKeyRegistrationDTO implements Serializable {
private byte[] appIconData;
private String appIconContentType;
@NotBlank(message = " 이름을 입력해주세요.")
@Length(max = 100, message = " 이름은 100자를 초과할 수 없습니다.")
@NotBlank(message = "클라이언트 이름을 입력해주세요.")
@Length(max = 100, message = "클라이언트 이름은 100자를 초과할 수 없습니다.")
private String appName;
@NotBlank(message = " 설명을 입력해주세요.")
@Length(max = 500, message = " 설명은 500자를 초과할 수 없습니다.")
@NotBlank(message = "클라이언트 설명을 입력해주세요.")
@Length(max = 500, message = "클라이언트 설명은 500자를 초과할 수 없습니다.")
private String appDescription;
private String callbackUrl;
@@ -99,6 +99,7 @@ public class ApiKeyRegistrationDTO implements Serializable {
}
public boolean isComplete() {
return isStep1Complete() && isStep2Complete();
// API 선택은 선택 사항이므로 기본 정보(Step1)만 완료되면 등록 가능하다.
return isStep1Complete();
}
}
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.NotEmpty;
import lombok.Data;
@Data
@@ -25,8 +24,7 @@ public class AppRequestDTO {
private ApprovalDTO approval;
@NotEmpty
private String apiList = ""; //comma separated api id list
private String apiList = ""; //comma separated api id list (미선택 허용)
private String apiGroupList = ""; //comma separated api group id list
@@ -37,7 +37,7 @@ public class AdminGatewayClient {
String baseUrl = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
@@ -51,4 +51,18 @@ public class AdminGatewayClient {
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
}
/**
* base URL 끝의 {@code '/'} 를 모두 걷어낸다.
*
* <p>{@code replaceAll("/+$", "")} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
* 선형 스캔으로 대체했다. 동작은 동일하다.</p>
*/
private static String stripTrailingSlashes(String url) {
int end = url.length();
while (end > 0 && url.charAt(end - 1) == '/') {
end--;
}
return url.substring(0, end);
}
}
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.statemachine.CreatedState;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
@@ -31,6 +32,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -59,21 +61,47 @@ public class AppServiceFacade {
private final ApiServiceHelper apiServiceHelper;
private final FileService fileService;
private final PasswordEncoder passwordEncoder;
private final AdminGatewayClient adminGatewayClient;
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList());
// 최근 수정(발급/변경) 순으로 정렬. 수정일 없는 건은 뒤로.
return clients.stream()
.sorted(Comparator.comparing(Credential::getModifiedon,
Comparator.nullsLast(Comparator.reverseOrder())))
.map(credentialMapper::toVo).collect(Collectors.toList());
}
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
List<AppRequestType> types = Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE);
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState()));
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
appRequests.sort(Comparator.comparingInt(this::pendingStatusRank)
.thenComparing(AppRequest::getCreatedDate, Comparator.nullsLast(Comparator.reverseOrder())));
return appRequests;
}
private int pendingStatusRank(AppRequest request) {
if (request.getApproval() == null) {
return 3;
}
if (request.getApproval().getApprovalStatus() instanceof ProcessingState) {
return 1;
}
if (request.getApproval().getApprovalStatus() instanceof RequestedState) {
return 2;
}
return 3;
}
public ClientDTO getApiKey(String orgid, String clientId) {
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
}
@@ -133,8 +161,121 @@ public class AppServiceFacade {
approvalService.beginApproval(approvalId);
}
/**
* API 이용 해지(DELETE) 결재 신청을 생성하고 결재를 개시합니다.
* GW 차단/삭제와 PTL_CREDENTIAL 삭제는 여기서 하지 않으며,
* eapim-admin 승인 시점에 PortalAppApprovalListener 가 수행합니다.
*
* @throws IllegalStateException 중복 신청, 변경 신청 진행 중, 승인라인 미등록 등 사용자에게 안내할 상황
*/
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
String blockReason = resolveDeleteBlockReason(clientId);
if (blockReason != null) {
throw new IllegalStateException(blockReason);
}
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
AppRequestDTO dto = new AppRequestDTO();
dto.setType(AppRequestType.DELETE);
dto.setClientId(clientId);
dto.setReason(reason);
dto.setOrg(portalOrgMapper.toVo(portalOrg));
AppRequestDTO saved = createAppRequest(dto);
// 3. 승인라인 미등록이면 approval 이 null — 결재 없는 해지 신청은 만들지 않는다(트랜잭션 롤백)
if (saved.getApproval() == null || saved.getApproval().getId() == null) {
throw new IllegalStateException("APP 승인라인이 등록되어 있지 않아 해지를 신청할 수 없습니다. 관리자에게 문의해 주세요.");
}
beginApproval(saved.getApproval().getId());
}
/**
* 해지 신청을 막는 사유가 있으면 안내 메시지를, 없으면 {@code null} 을 반환합니다.
*
* <p>미완료 해지·변경 신청이 있으면 해지를 받을 수 없다. {@link #createDeleteRequest} 의 최종 가드와
* 화면/사전검사가 <b>같은 판정</b>({@link #findOpenRequestEntity})을 쓰도록 이 메서드 하나로 모은다.
* (사전검사가 없으면 사용자가 step-up 2FA 를 통과한 뒤에야 차단 사실을 알게 되고,
* 재시도할 때마다 2FA 가 반복된다.)</p>
*/
public String resolveDeleteBlockReason(String clientId) {
AppRequest open = findOpenRequestEntity(clientId);
if (open == null) {
return null;
}
if (AppRequestType.DELETE.equals(open.getType())) {
return "이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.";
}
return "해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.";
}
/**
* 해당 인증키에 걸려 있는 <b>미완료</b> 변경/해지 신청 1건. 없으면 {@code null}.
* 상세 화면에서 진행 상태를 표시하고 신청을 취소할 수 있게 하는 데 사용한다.
*/
public AppRequestDTO findOpenRequest(String clientId) {
AppRequest open = findOpenRequestEntity(clientId);
return open == null ? null : appRequestMapper.toVo(open);
}
/**
* 미완료(=결재가 끝나지 않은) 변경/해지 신청 1건.
*
* <p>결재가 개시되지 않아 {@code approval} 이 없는 신청도 포함한다. 이런 건은 관리자 결재함에
* 뜨지 않아 사용자가 상태를 알 수도, 정리할 수도 없는 채로 남아 이후 신청을 계속 막는다.
* 화면에 노출해 취소할 수 있게 하려면 여기서 잡아야 한다.</p>
*/
private AppRequest findOpenRequestEntity(String clientId) {
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
for (AppRequest r : related) {
if (r.getApproval() == null) {
return r;
}
Object status = r.getApproval().getApprovalStatus();
if (status instanceof CreatedState
|| status instanceof RequestedState
|| status instanceof ProcessingState) {
return r;
}
}
return null;
}
/**
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
*/
public boolean hasPendingDeleteRequest(String clientId) {
return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
clientId, Arrays.asList(AppRequestType.DELETE)).stream()
.anyMatch(r -> r.getApproval() != null
&& (r.getApproval().getApprovalStatus() instanceof RequestedState
|| r.getApproval().getApprovalStatus() instanceof ProcessingState));
}
public void cancelApiRequest(String id, PortalOrg portalOrg) {
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
if (request.getApproval() == null) {
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
// DELETE(해지) 신청은 살아있는 클라이언트가 대상이므로 취소 시 GW 를 건드리면 안 된다.
if (StringUtils.isNotBlank(request.getClientId())
&& !AppRequestType.DELETE.equals(request.getType())) {
try {
adminGatewayClient.blockClient(request.getClientId());
} catch (Exception e) {
throw new IllegalStateException("게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.", e);
}
}
appRequestRepository.delete(request);
} else {
approvalService.cancelAppApproval(request);
}
});
}
@@ -164,9 +305,15 @@ public class AppServiceFacade {
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
for (String apiId : apiList) {
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
// 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
spec.setService(serviceDTO.getGroupName());
if (spec == null) {
continue;
}
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
if (serviceDTO != null) {
spec.setService(serviceDTO.getGroupName());
}
appRequest.getApiSpecList().add(spec);
}
}
@@ -316,18 +463,4 @@ public class AppServiceFacade {
return secret;
}
/**
* API Key(Credential)를 즉시 삭제합니다.
* 승인 프로세스 없이 바로 삭제 처리됩니다.
*
* @param orgId 조직 ID
* @param clientId 삭제할 클라이언트 ID
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
*/
public void deleteApp(String orgId, String clientId) {
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
credentialRepository.delete(credential);
}
}
@@ -41,7 +41,13 @@ public class ApprovalService {
approval.setApprovalType(ApprovalType.APP);
approval.setTargetId(request.getId());
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "] " + request.getType().getDescription() + " 승인");
// 동일 법인의 신규/변경/해지 요청이 관리자 목록에서 같은 제목으로 보이면 대상 식별이 불가능하다.
// 클라이언트 이름을 제목에 포함해 운영자와 E2E 모두 정확한 승인 건을 검색할 수 있게 한다.
String clientName = request.getClientName();
String clientNamePart = clientName == null || clientName.trim().isEmpty()
? "" : " [" + clientName.trim() + "]";
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "]" + clientNamePart
+ " " + request.getType().getDescription() + " 승인");
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
@@ -0,0 +1,73 @@
package com.eactive.apim.portal.apps.auth;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 인증(이메일/SMS) 테스트 안내 관련 PTL_PROPERTY 접근 래퍼.
*
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
* {@link com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties} 와 동일한
* {@code getOrCreateProperty} 패턴을 따른다.</p>
*
* <p><b>운영(prod)에서도 DB 값만으로 켤 수 있다.</b> 대신 종료일
* {@code auth.test-notice.prod-until}({@code yyyy-MM-dd})을 함께 지정해야 하며, 날짜가 지나면
* {@link TestNoticeWindow} 가 자동으로 닫는다. 종료일 미설정/형식 오류면 운영에서는 열리지 않는다.
* 운영 오픈 전 UMS 실발송 미연계 기간을 위한 한시 스위치다.</p>
*
* <p>이 판정은 {@link com.eactive.apim.portal.common.breadcrumb.GlobalControllerAdvice} 의
* {@code @ModelAttribute} 에서 <b>모든 페이지 요청마다</b> 호출된다. PortalPropertyService 에 캐시가
* 없어 호출마다 DB 조회가 발생하므로 {@value #CACHE_TTL_MILLIS}ms 짧은 캐시를 둔다.
* PTL_PROPERTY 를 바꾸면 최대 그 시간만큼 반영이 늦다.</p>
*/
@Component
@RequiredArgsConstructor
public class AuthNoticeProperties {
public static final String GROUP = "Portal";
public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
public static final String KEY_TEST_NOTICE_PROD_UNTIL = "auth.test-notice.prod-until";
/** 판정 결과 캐시 유효시간(ms) */
static final long CACHE_TTL_MILLIS = 30_000L;
private final PortalPropertyService portalPropertyService;
private final TestNoticeWindow testNoticeWindow;
private volatile boolean cachedEnabled;
/** 캐시 갱신 시각(ms). 0 이면 미조회 */
private volatile long cachedAt;
/**
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
* 운영에서는 종료일({@link #KEY_TEST_NOTICE_PROD_UNTIL})까지만 참이 된다.
*/
public boolean isTestNoticeEnabled() {
long now = System.currentTimeMillis();
long at = cachedAt;
if (at != 0L && now - at < CACHE_TTL_MILLIS) {
return cachedEnabled;
}
boolean enabled = resolveEnabled();
cachedEnabled = enabled;
cachedAt = now;
return enabled;
}
private boolean resolveEnabled() {
// 기본값 false - row 가 없는 환경(신규 운영 DB 등)에서 자동으로 켜지지 않도록 한다.
String value = portalPropertyService.getOrCreateProperty(
GROUP, KEY_TEST_NOTICE_ENABLED, "false",
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
if (value == null || !"true".equalsIgnoreCase(value.trim())) {
return false;
}
String until = portalPropertyService.getOrCreateProperty(
GROUP, KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
"운영(prod)에서 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED);
}
}
@@ -0,0 +1,74 @@
package com.eactive.apim.portal.apps.auth;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
/**
* 인증번호 화면 노출(테스트 안내) 스위치의 <b>운영 환경 사용 기한</b> 판정기.
*
* <p>운영 오픈 전에는 UMS/EAI 실발송 연계가 끝나지 않아 인증번호가 실제로 도달하지 않을 수 있다.
* 이 기간에는 prod 에서도 인증번호를 화면에 노출해야 테스트가 가능하다. 다만 스위치 끄기를 잊으면
* 인증번호가 상시 노출되므로, prod 에서는 <b>종료일을 명시한 경우에만</b> 열어 주고 그 날짜가 지나면
* PTL_PROPERTY 값과 무관하게 자동으로 닫는다.</p>
*
* <ul>
* <li>비운영(!prod) — 항상 열림. 종료일을 보지 않는다.</li>
* <li>운영(prod) — 종료일이 없거나 형식이 잘못되면 <b>닫힘</b>(fail-safe). 종료일 당일까지 열림.</li>
* </ul>
*
* <p>이메일/휴대폰 인증({@link AuthNoticeProperties})과 2차 인증
* ({@code TwoFactorProperties})이 같은 규칙을 쓰도록 판정을 이 클래스로 모은다.
* 종료일 프로퍼티 값은 {@code yyyy-MM-dd} 형식이며, 미설정을 뜻하는 기본값은 {@value #UNSET}
* ({@code PTL_PROPERTY.property_value} 가 NOT NULL 이라 빈 문자열을 기본값으로 쓸 수 없다).</p>
*/
@Component
@Slf4j
public class TestNoticeWindow {
/** 종료일 미설정을 뜻하는 기본값. prod 에서는 이 값이면 닫힘 */
public static final String UNSET = "none";
private final Environment environment;
public TestNoticeWindow(Environment environment) {
this.environment = environment;
}
/**
* @param untilValue 종료일 문자열({@code yyyy-MM-dd}). PTL_PROPERTY 값 원본
* @param subject 로그 식별용 스위치 이름
* @return 지금 인증번호를 화면에 노출해도 되는 기간인지 여부
*/
public boolean isOpen(String untilValue, String subject) {
if (!environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
LocalDate until = parseDate(untilValue);
if (until == null) {
return false;
}
if (LocalDate.now().isAfter(until)) {
return false;
}
// 운영에서 인증번호가 화면에 노출되는 상태 - 흔적을 남긴다.
log.warn("운영 환경 인증번호 노출 활성 상태 - {} (종료일 {})", subject, until);
return true;
}
private static LocalDate parseDate(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
try {
return LocalDate.parse(value.trim());
} catch (DateTimeParseException e) {
log.warn("인증번호 노출 종료일 형식 오류(yyyy-MM-dd 필요) - {}", value);
return null;
}
}
}
@@ -64,33 +64,36 @@ public class ApiPermissionFilter {
.collect(Collectors.toList());
}
private static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
/**
* org 축과 role 축은 각자 독립적으로 선택적 제한이다(비어있으면 그 축은 통과) — 둘을 AND로 합친다.
* (과거 OR 합성은 한쪽 축이 비어있으면 그 축이 무조건 true가 되어 다른 쪽 제한을 무력화시키는
* 결함이 있었다 — 예: displayOrg 만 설정하고 displayRoleCode 를 비워두면 roleMatch 가 로그인
* 여부만으로 true 가 되어 조직 제한이 사실상 적용되지 않았다.)
*/
static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
String org, String roleCode) {
if (!isAuthenticated) {
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
}
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
spec.getDisplayOrg().isEmpty() ||
spec.getDisplayOrg().contains(org));
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
spec.getDisplayRoleCode().isEmpty() ||
spec.getDisplayRoleCode().contains(roleCode));
return orgMatch || roleMatch;
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|| (org != null && spec.getDisplayOrg().contains(org));
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
return orgOk && roleOk;
}
private static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
/** {@link #checkServicePermissions} 와 동일 규칙(중복 유지 — 대상 DTO 타입만 다름). */
static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
String org, String roleCode) {
if (!isAuthenticated) {
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
}
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
spec.getDisplayOrg().isEmpty() ||
spec.getDisplayOrg().contains(org));
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
spec.getDisplayRoleCode().isEmpty() ||
spec.getDisplayRoleCode().contains(roleCode));
return orgMatch || roleMatch;
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|| (org != null && spec.getDisplayOrg().contains(org));
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
return orgOk && roleOk;
}
}
@@ -4,5 +4,23 @@ public interface AuthNumberService {
String sendRequestAuthNumber(String recipientKey, String msgType);
/**
* 기본 TTL 로 발송하되 수신자 이름을 지정한다. 세 번째 인자가 int 인 오버로드(TTL 지정)와 혼동하지 말 것.
*/
String sendRequestAuthNumber(String recipientKey, String msgType, String username);
/**
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
*/
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
/**
* 수신자 이름을 지정해 인증번호를 발송한다. 메시지 템플릿의 %USER_NAME% 치환에 사용되며,
* 회원가입·아이디/비밀번호 찾기처럼 사용자 이름을 알 수 없는 흐름은 "guest" 를 넘긴다.
* username 이 비어 있으면 %USER_NAME% 은 치환되지 않고 원문이 그대로 남는다.
*/
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username);
boolean verifyAuthNumber(String recipientKey, String authNumber);
}
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
@Service
@@ -45,17 +46,35 @@ public class AuthNumberServiceImpl implements AuthNumberService {
@Override
@Transactional(noRollbackFor = AuthNumberException.class)
public String sendRequestAuthNumber(String recipientKey, String msgType) {
logger.info("Sending auth number to: {} via {}", recipientKey, msgType);
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, null);
}
@Override
@Transactional(noRollbackFor = AuthNumberException.class)
public String sendRequestAuthNumber(String recipientKey, String msgType, String username) {
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, username);
}
@Override
@Transactional(noRollbackFor = AuthNumberException.class)
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
return sendRequestAuthNumber(recipientKey, msgType, ttlSeconds, null);
}
@Override
@Transactional(noRollbackFor = AuthNumberException.class)
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username) {
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
validateResendTime(recipientKey);
String authNumber = generator.generateAuthNumber();
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType);
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType, username);
messageSender.sendAuthMessage(recipient, authNumber, msgType);
storage.saveAuthNumber(recipientKey, authNumber,
LocalDateTime.now().plusSeconds(authNumberExpirationTime));
LocalDateTime.now().plusSeconds(ttlSeconds));
return authNumber;
}
@@ -66,33 +85,46 @@ public class AuthNumberServiceImpl implements AuthNumberService {
logger.info("Verifying auth number for: {}", recipientKey);
TwoFactorAuth storedAuth = storage.getAuthNumber(recipientKey)
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요."));
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요.",
AuthNumberException.Reason.NOT_FOUND));
if (storedAuth.getExpiresAt().isBefore(LocalDateTime.now())) {
storage.deleteAuthNumber(recipientKey);
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.");
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.",
AuthNumberException.Reason.EXPIRED);
}
if (authNumber.equals(storedAuth.getAuthNumber())) {
return true;
} else {
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.");
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.",
AuthNumberException.Reason.MISMATCH);
}
}
private void validateResendTime(String recipientKey) {
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
LocalDateTime now = LocalDateTime.now();
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds).isAfter(now)) {
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
.minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds);
if (resendAvailableAt.isAfter(now)) {
long remainingMillis = Duration.between(now, resendAvailableAt).toMillis();
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
throw new AuthNumberException(
String.format("인증번호 재발송 제한이 적용 중입니다. %d초 후 다시 시도해 주세요.",
remainingSeconds));
}
});
}
private MessageRecipient createMessageRecipient(String recipientKey, String msgType) {
private MessageRecipient createMessageRecipient(String recipientKey, String msgType, String username) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(recipientKey);
// 메시지 템플릿 %USER_NAME% 치환용. 비어 있으면 MessageSendService 가 파라미터 자체를 넣지 않는다.
if (username != null && !username.trim().isEmpty()) {
recipient.setUsername(username);
}
if ("SMS".equalsIgnoreCase(msgType)) {
recipient.setPhone(recipientKey);
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
@@ -0,0 +1,80 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* step-up 2FA(민감기능 추가 인증) 진입 가드.
*
* <p>{@link StepUpProtectedPaths#isInterceptorGuarded(String)} 경로 진입 시, 유효한 1회용
* 통과권이 없으면 2FA 를 요구한다.
* <ul>
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
* </ul>
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
*
* <p>내 정보 변경({@code /mypage}, PASSWORD 레벨)과 비밀번호 반영({@code POST /password/change},
* 반영 직전 2FA)은 각 컨트롤러가 직접 관장하므로 이 인터셉터 대상이 아니다.</p>
*/
public class StepUpAuthInterceptor implements HandlerInterceptor {
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
public StepUpAuthInterceptor(TwoFactorService twoFactorService, TwoFactorProperties twoFactorProperties) {
this.twoFactorService = twoFactorService;
this.twoFactorProperties = twoFactorProperties;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!twoFactorProperties.isStepUpEnabled()) {
return true;
}
String path = request.getServletPath();
if (!StepUpProtectedPaths.isInterceptorGuarded(path)) {
return true;
}
// 지점별 스위치가 꺼져 있으면 해당 경로는 step-up 미적용
if (!twoFactorProperties.isStepUpPointEnabled(path)) {
return true;
}
HttpSession session = request.getSession(false);
if (session == null) {
// 세션(=인증)이 없으면 여기서 다루지 않고 보안 계층(@Secured)에 맡긴다.
return true;
}
// 1회용 통과권 소비 시도 (매번 인증: 있으면 소멸 후 통과)
if (twoFactorService.consumeStepUpPass(session, path)) {
return true;
}
if ("POST".equalsIgnoreCase(request.getMethod())) {
// AJAX 지점(Secret 조회/앱 해지) → 프론트가 팝업을 띄우도록 신호
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"stepUpRequired\":true}");
return false;
}
// GET 페이지 진입 → 챌린지 페이지로 유도(원경로+쿼리 보존)
String returnUrl = path;
String query = request.getQueryString();
if (query != null && !query.isEmpty()) {
returnUrl = returnUrl + "?" + query;
}
String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8.name());
response.sendRedirect(request.getContextPath() + "/auth/2fa/challenge?returnUrl=" + encoded);
return false;
}
}
@@ -0,0 +1,88 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker.Outcome;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* 완화된 step-up(PASSWORD 레벨) 확인 페이지.
*
* <p>{@link StepUpProtectedPaths#isPasswordGated(String)} 경로(예: {@code /mypage})는 2FA 대신
* <b>현재 비밀번호 재확인</b>만 요구한다. 각 컨트롤러가 통과권이 없을 때 이 페이지로 유도하고,
* 확인 성공 시 해당 경로의 통과권을 발급한 뒤 원경로로 복귀시킨다.</p>
*
* <p>returnUrl 은 PASSWORD 레벨 화이트리스트로만 검증·복귀하여 open redirect 를 막는다.</p>
*/
@Controller
@Secured("ROLE_ACCOUNT")
@RequiredArgsConstructor
@RequestMapping("/auth/stepup")
public class StepUpPasswordController {
private final UserFacade userFacade;
private final TwoFactorService twoFactorService;
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
@GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
}
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
@PostMapping("/password")
public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl,
HttpSession session, HttpServletRequest request, HttpServletResponse response,
Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
}
String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
passwordConfirmFailureTracker.reset(session);
twoFactorService.grantStepUpPass(session, path);
return "redirect:" + path;
}
// 연속 실패 카운트 증가. 임계치 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다(무차별 대입 방어).
Outcome outcome = passwordConfirmFailureTracker.recordFailure(session, request, response);
if (outcome.isForcedLogout()) {
return outcome.isAccountLocked()
? "redirect:/login?pwFailExceeded=1&locked=1"
: "redirect:/login?pwFailExceeded=1";
}
model.addAttribute("error", outcome.getMessage());
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
private static String pathOf(String url) {
if (url == null) {
return null;
}
int q = url.indexOf('?');
return q >= 0 ? url.substring(0, q) : url;
}
}
@@ -0,0 +1,126 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* step-up(민감기능 추가 인증) 대상 서블릿 경로 화이트리스트 + 지점별 프로퍼티 키 + 검증 레벨.
*
* <p>검증 레벨(완화 정책)</p>
* <ul>
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
* </ul>
*
* <p>세 가지 관심사를 분리한다.</p>
* <ol>
* <li>{@link #isTwoFactorPurpose(String)} — 공통 2FA 팝업의 유효 대상(purpose) 화이트리스트.
* open redirect / 임의 purpose 로의 2FA 발송·통과권 발급을 막는 데 쓴다.</li>
* <li>{@link #isInterceptorGuarded(String)} — {@link StepUpAuthInterceptor} 가 진입 시점에
* 자동 차단하는 경로. 비밀번호 변경(반영 직전 확인)·내 정보(별도 확인 페이지)는
* 각 컨트롤러가 직접 관장하므로 여기서 제외한다.</li>
* <li>{@link #isPasswordGated(String)} — 현재 비밀번호 재확인으로 보호하는 경로.</li>
* </ol>
*
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어하며
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
*/
public final class StepUpProtectedPaths {
/** step-up 검증 레벨(완화 정책) */
public enum Level {
/** 공통 2FA 팝업(인증번호) */
TWO_FACTOR,
/** 현재 비밀번호 재확인만 */
PASSWORD
}
/** Secret 키 조회 (AJAX POST) */
public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
/** 앱 해지 신청 (AJAX POST) */
public static final String APP_KEY_DELETE = "/clients/api_key_delete";
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
public static final String PASSWORD_CHANGE = "/password/change";
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
public static final String WITHDRAW = "/withdraw";
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
private static final String KEY_PREFIX = "two-factor.stepup.";
/** 경로 → 지점별 프로퍼티 키. 삽입 순서 유지(LinkedHashMap) */
private static final Map<String, String> PATH_TO_KEY;
/** 경로 → 검증 레벨 */
private static final Map<String, Level> PATH_TO_LEVEL;
/** 인터셉터가 진입 시점에 자동 차단하는 경로(2FA) */
private static final Set<String> INTERCEPTOR_GUARDED;
static {
Map<String, String> keys = new LinkedHashMap<>();
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
keys.put(MYPAGE, KEY_PREFIX + "mypage");
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
PATH_TO_KEY = Collections.unmodifiableMap(keys);
Map<String, Level> levels = new LinkedHashMap<>();
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
levels.put(MYPAGE, Level.PASSWORD);
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
levels.put(WITHDRAW, Level.TWO_FACTOR);
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
Set<String> guarded = new java.util.LinkedHashSet<>();
guarded.add(REVEAL_SECRET);
guarded.add(APP_KEY_DELETE);
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
}
private StepUpProtectedPaths() {
}
/** 공통 2FA 팝업의 유효 대상(purpose)인지 — open redirect / 임의 purpose 차단용 */
public static boolean isTwoFactorPurpose(String servletPath) {
return servletPath != null
&& PATH_TO_LEVEL.get(servletPath) == Level.TWO_FACTOR;
}
/** 인터셉터가 진입 시점에 자동 차단하는 경로인지 */
public static boolean isInterceptorGuarded(String servletPath) {
return servletPath != null && INTERCEPTOR_GUARDED.contains(servletPath);
}
/** 현재 비밀번호 재확인으로 보호하는 경로인지(PASSWORD 레벨) */
public static boolean isPasswordGated(String servletPath) {
return servletPath != null && PATH_TO_LEVEL.get(servletPath) == Level.PASSWORD;
}
/** 해당 경로의 지점별 활성화 프로퍼티 키. 대상 경로가 아니면 null */
public static String propertyKeyOf(String servletPath) {
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
}
/** 지점별 프로퍼티 키 접두 */
public static String keyPrefix() {
return KEY_PREFIX;
}
}
@@ -0,0 +1,43 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 만료된 2FA 인증번호(PTL_TWO_FACTOR_AUTH) 정리 스케줄러.
*
* <p>검증은 접근 시점 lazy 만료 검사만 하므로, 발송 후 검증 없이 방치된 레코드가 남는다.
* 1분 주기로 만료분을 일괄 삭제한다.</p>
*
* <p><b>다중화(스케일아웃) 안전성:</b> 작업이 "만료된 행만" 지우는 멱등 delete 라
* 여러 인스턴스가 동시에 실행해도 결과가 동일하고 부작용이 없다. 따라서 분산 락
* (ShedLock 등)이 필요 없다. 동일 행을 둘이 지우려 하면 한쪽이 0건 삭제로 끝날 뿐이다.</p>
*/
@Component
@RequiredArgsConstructor
public class TwoFactorCleanupScheduler {
private static final Logger log = LoggerFactory.getLogger(TwoFactorCleanupScheduler.class);
private final TwoFactorAuthRepository twoFactorAuthRepository;
@Scheduled(fixedRate = 60000)
@Transactional
public void cleanupExpired() {
try {
int deleted = twoFactorAuthRepository.deleteAllByExpiresAtBefore(LocalDateTime.now());
if (deleted > 0) {
log.debug("만료된 2FA 인증번호 {}건 정리", deleted);
}
} catch (Exception e) {
log.warn("2FA 인증번호 정리 실패", e);
}
}
}
@@ -0,0 +1,102 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 진행 중인 2FA 절차 상태. HTTP 세션에 단일 소스로 보관한다.
*
* <p>세션 클러스터링(stage/prod Redis/Ehcache) 대상이므로 {@link Serializable} 이다.
* 여러 탭/페이지에서 동시에 2FA 가 발동되지 않도록, 발송 시 이 컨텍스트 존재 여부로
* "진행 중" 을 판정하고 confirm 후 강제 종료(force)로만 새 절차를 시작한다.</p>
*/
public class TwoFactorContext implements Serializable {
private static final long serialVersionUID = 1L;
public enum Mode {
/** 로그인 1차 인증 통과 후 대기(pending) 상태의 2FA */
LOGIN,
/** 로그인 이후 민감기능 접근 시 추가 인증(step-up) */
STEPUP
}
private Mode mode;
/** 발송 채널 (EMAIL | SMS) */
private String channel;
/** AuthNumberService 에 전달한 실제 수신처 문자열(이메일 소문자 / 휴대폰 digits). 검증 시 동일 값 사용 */
private String recipient;
/** step-up 대상 보호 경로(purpose). LOGIN 모드에서는 null */
private String purpose;
/** 발송 시각 */
private LocalDateTime startedAt;
/** 유효시간(초) */
private int ttlSeconds;
/** 검증 시도 횟수 */
private int attempts;
public Mode getMode() {
return mode;
}
public void setMode(Mode mode) {
this.mode = mode;
}
public String getChannel() {
return channel;
}
public void setChannel(String channel) {
this.channel = channel;
}
public String getRecipient() {
return recipient;
}
public void setRecipient(String recipient) {
this.recipient = recipient;
}
public String getPurpose() {
return purpose;
}
public void setPurpose(String purpose) {
this.purpose = purpose;
}
public LocalDateTime getStartedAt() {
return startedAt;
}
public void setStartedAt(LocalDateTime startedAt) {
this.startedAt = startedAt;
}
public int getTtlSeconds() {
return ttlSeconds;
}
public void setTtlSeconds(int ttlSeconds) {
this.ttlSeconds = ttlSeconds;
}
public int getAttempts() {
return attempts;
}
public void setAttempts(int attempts) {
this.attempts = attempts;
}
public int incrementAttempts() {
return ++this.attempts;
}
/** startedAt + ttl 기준 만료 여부(세션 컨텍스트 lazy 만료 판정용) */
public boolean isExpired(LocalDateTime now) {
return startedAt == null || startedAt.plusSeconds(ttlSeconds).isBefore(now);
}
}
@@ -0,0 +1,90 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
/**
* 공통 2FA 팝업 백엔드. 로그인 pending·step-up 을 모두 처리한다.
*
* <p>수신처는 서버가 세션 대상 사용자로부터 결정하므로 클라이언트는 채널만 전달한다.
* 모든 POST 는 세션 기반 CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
*/
@Controller
@RequestMapping("/auth/2fa")
@RequiredArgsConstructor
public class TwoFactorController {
private final TwoFactorService twoFactorService;
/** 팝업 초기 정보(채널·TTL·진행중 여부) */
@GetMapping("/info")
@ResponseBody
public TwoFactorInfoResponse info(@RequestParam(required = false) String purpose, HttpSession session) {
return twoFactorService.getInfo(session, purpose);
}
/** 인증번호 발송 */
@PostMapping("/send")
@ResponseBody
public TwoFactorSendResponse send(@RequestParam String channel,
@RequestParam(required = false) String purpose,
@RequestParam(required = false, defaultValue = "false") boolean force,
HttpServletRequest request,
HttpSession session) {
return twoFactorService.send(request, session, channel, purpose, force);
}
/** 인증번호 검증 */
@PostMapping("/verify")
@ResponseBody
public TwoFactorVerifyResponse verify(@RequestParam String code,
HttpServletRequest request,
HttpSession session) {
return twoFactorService.verify(request, session, code);
}
/** 팝업 닫기/타이머 만료 → 2차 인증 실패 처리 */
@PostMapping("/cancel")
@ResponseBody
public void cancel(@RequestParam(required = false, defaultValue = "CANCELLED") String reason,
HttpServletRequest request,
HttpSession session) {
twoFactorService.cancel(request, session, reason);
}
/**
* step-up GET 진입 지점용 챌린지 페이지. 인터셉터가 리다이렉트하며, 화면이 공통 팝업을 자동 오픈한다.
* returnUrl 은 보호 경로 화이트리스트로 검증(open redirect 방지)한다.
*/
@GetMapping("/challenge")
public String challenge(@RequestParam(required = false) String returnUrl, Model model) {
// returnUrl 은 쿼리스트링을 포함할 수 있으므로 경로 부분만 화이트리스트로 검증(open redirect 방지)
String purpose = pathOf(returnUrl);
if (!StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
return "redirect:/";
}
model.addAttribute("returnUrl", returnUrl);
model.addAttribute("purpose", purpose);
return "apps/auth/twoFactorChallenge";
}
private static String pathOf(String url) {
if (url == null) {
return null;
}
int q = url.indexOf('?');
return q >= 0 ? url.substring(0, q) : url;
}
}
@@ -0,0 +1,134 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.auth.TestNoticeWindow;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 2FA 관련 PTL_PROPERTY 접근 래퍼.
*
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례({@code session.timeout.minutes},
* {@code org.hard-delete.enabled} 등)를 따른다.
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
* 생성(그룹 존재 시)하므로 별도 초기 데이터가 필요 없다. 캐시가 없어 매 호출 DB 조회지만
* 2FA 진입 경로가 제한적이라 허용 범위다.</p>
*/
@Component
@RequiredArgsConstructor
public class TwoFactorProperties {
public static final String GROUP = "Portal";
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
public static final String KEY_TEST_NOTICE_PROD_UNTIL = "two-factor.test-notice.prod-until";
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
private final PortalPropertyService portalPropertyService;
private final TestNoticeWindow testNoticeWindow;
/** 로그인 2FA 활성화 여부 */
public boolean isLoginEnabled() {
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
}
/**
* 로그인 2FA 적용 대상 역할인지 여부.
* 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
* 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
* 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
*/
public boolean isLoginTargetRole(RoleCode roleCode) {
if (roleCode == null) {
roleCode = RoleCode.ROLE_USER;
}
String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
"로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
if (value == null || value.trim().isEmpty()) {
return false;
}
String trimmed = value.trim();
if ("ALL".equalsIgnoreCase(trimmed)) {
return true;
}
for (String token : trimmed.split(",")) {
if (roleCode.name().equalsIgnoreCase(token.trim())) {
return true;
}
}
return false;
}
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
public boolean isStepUpEnabled() {
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
}
/**
* 특정 보호 경로에 step-up 2FA 를 적용할지 여부(지점별 스위치).
* 전체 스위치({@link #isStepUpEnabled()})가 켜진 상태에서 지점별로 개별 on/off 한다.
* 지점 프로퍼티({@code two-factor.stepup.<지점>})의 기본값은 true(전체 스위치를 켜면 기본 전 지점 적용).
*
* @param servletPath 보호 경로. 매핑 키가 없으면(비보호 경로) false
*/
public boolean isStepUpPointEnabled(String servletPath) {
String key = StepUpProtectedPaths.propertyKeyOf(servletPath);
if (key == null) {
return false;
}
return parseBool(resolve(key, "true", "step-up 2FA 지점 적용 여부 (true/false): " + servletPath));
}
/** 2FA 인증번호 유효시간(초). 기본 180초(3분) */
public int getTtlSeconds() {
return parseInt(resolve(KEY_TTL_SECONDS, "180", "2차 인증번호 유효시간(초)"), 180);
}
/** 인증번호 검증 시도 한도. 기본 5회 */
public int getAttemptLimit() {
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
}
/**
* 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용).
*
* <p>운영(prod)에서는 종료일 {@link #KEY_TEST_NOTICE_PROD_UNTIL}({@code yyyy-MM-dd})을 함께
* 지정한 경우에만 참이 되고, 날짜가 지나면 {@link TestNoticeWindow} 가 자동으로 닫는다.
* 종료일 미설정/형식 오류면 운영에서는 열리지 않는다. 이메일/휴대폰 인증
* ({@code auth.test-notice.*})과 동일한 규칙이다.</p>
*/
public boolean isTestNoticeEnabled() {
if (!parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"))) {
return false;
}
String until = resolve(KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
"운영(prod)에서 2차 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED);
}
private String resolve(String key, String defaultValue, String description) {
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
}
/**
* boolean PTL_PROPERTY 값 파싱.
* DB 관례에 맞춰 <b>true/false</b> 문자열을 사용한다(예: {@code org.hard-delete.enabled=true}).
* "true"(대소문자 무시)만 참으로 본다. 그 외(false/공백/null 등)는 모두 거짓.
*/
private static boolean parseBool(String value) {
return value != null && "true".equalsIgnoreCase(value.trim());
}
private static int parseInt(String value, int fallback) {
try {
return Integer.parseInt(value.trim());
} catch (Exception e) {
return fallback;
}
}
}
@@ -0,0 +1,510 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.auth.service.AuthNumberService;
import com.eactive.apim.portal.apps.auth.service.AuthNumberStorage;
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorChannel;
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
import com.eactive.apim.portal.apps.login.constants.LoginType;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* 2FA(2차 인증/추가 인증) 공통 서비스. 로그인 pending 인증과 step-up(민감기능) 인증을 모두 처리한다.
*
* <p>핵심 원칙:
* <ul>
* <li>수신처는 서버가 세션의 대상 사용자로부터 DB 기준으로 결정한다(클라이언트는 채널만 선택).</li>
* <li>진행 상태는 세션 {@link TwoFactorContext} 단일 소스로 관리한다.</li>
* <li>다른 플로우가 진행 중이면 발송을 막고(inProgress), confirm 후 force 로만 강제 종료·재시작한다.</li>
* </ul>
*/
@Service
@Transactional
@RequiredArgsConstructor
public class TwoFactorService {
private static final Logger log = LoggerFactory.getLogger(TwoFactorService.class);
// === 세션 attribute 키 ===
/** 로그인 1차 인증 통과 후 대기 중인 사용자 id (존재 시 LOGIN 모드) */
public static final String ATTR_PENDING_USER_ID = "TFA_PENDING_USER_ID";
/** 대기 중 사용자 loginId (감사/세션 표기) */
public static final String ATTR_PENDING_LOGIN_ID = "TFA_PENDING_LOGIN_ID";
/** 진행 중 2FA 컨텍스트 */
public static final String ATTR_CONTEXT = "TFA_CONTEXT";
/** step-up 1회용 통과권 - 대상 경로 */
public static final String ATTR_STEPUP_PASS_PATH = "TFA_STEPUP_PASS_PATH";
/** step-up 1회용 통과권 - 발급 시각 */
public static final String ATTR_STEPUP_PASS_AT = "TFA_STEPUP_PASS_AT";
/** step-up 통과권 유효시간(초). 인증 성공 후 대상 페이지 진입까지의 이동 여유분 */
public static final int STEPUP_PASS_TTL_SECONDS = 120;
/** 재발송/채널전환 통합 최소 간격(초) */
private static final int RESEND_THROTTLE_SECONDS = 30;
private final TwoFactorProperties properties;
private final AuthNumberService authNumberService;
private final AuthNumberStorage authNumberStorage;
private final PortalUserRepository portalUserRepository;
private final PortalUserAuthService portalUserAuthService;
private final PortalUserLogService userLogService;
private final LoginFinalizer loginFinalizer;
// =========================================================================
// INFO
// =========================================================================
public TwoFactorInfoResponse getInfo(HttpSession session, String purpose) {
TwoFactorInfoResponse res = new TwoFactorInfoResponse();
TwoFactorContext.Mode mode = resolveMode(session);
if (mode == null) {
res.setAvailable(false);
return res;
}
PortalUser user = resolveTargetUser(session, mode);
if (user == null) {
res.setAvailable(false);
return res;
}
res.setAvailable(true);
res.setMode(mode.name());
res.setChannels(buildChannels(user));
res.setTtlSeconds(properties.getTtlSeconds());
res.setTestNoticeEnabled(properties.isTestNoticeEnabled());
TwoFactorContext ctx = getActiveContext(session);
if (ctx != null && !isSameFlow(ctx, mode, purpose)) {
res.setInProgress(true);
res.setMessage("진행 중인 다른 인증 절차가 있습니다.");
}
return res;
}
// =========================================================================
// SEND
// =========================================================================
public TwoFactorSendResponse send(HttpServletRequest request, HttpSession session, String channel,
String purpose, boolean force) {
TwoFactorSendResponse res = new TwoFactorSendResponse();
TwoFactorContext.Mode mode = resolveMode(session);
if (mode == null) {
res.setValid(false);
res.setMessage("인증 대상 정보가 없습니다. 다시 시도해주세요.");
return res;
}
if (mode == TwoFactorContext.Mode.STEPUP && !StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
res.setValid(false);
res.setMessage("허용되지 않은 요청입니다.");
return res;
}
PortalUser user = resolveTargetUser(session, mode);
if (user == null) {
res.setValid(false);
res.setMessage("인증 대상 사용자를 찾을 수 없습니다.");
return res;
}
String normalizedChannel = channel == null ? "" : channel.trim().toUpperCase();
String recipient = resolveRecipient(user, normalizedChannel);
if (recipient == null) {
res.setValid(false);
res.setMessage("선택한 방법으로 인증할 수 있는 정보가 없습니다.");
return res;
}
// 진행 중 컨텍스트 처리
TwoFactorContext ctx = getActiveContext(session);
if (ctx != null) {
boolean sameFlow = isSameFlow(ctx, mode, purpose);
if (!sameFlow) {
if (!force) {
res.setValid(false);
res.setInProgress(true);
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
return res;
}
discardContext(request, session, ctx); // 강제 종료(감사 기록 포함)
} else if (ctx.getStartedAt() != null
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
res.setValid(false);
res.setMessage("잠시 후에 다시 시도해 주세요.");
return res;
}
}
int ttl = properties.getTtlSeconds();
String authNumber;
try {
authNumber = authNumberService.sendRequestAuthNumber(recipient,
"SMS".equals(normalizedChannel) ? "SMS" : "EMAIL", ttl);
} catch (AuthNumberException e) {
res.setValid(false);
res.setMessage(e.getMessage());
return res;
}
TwoFactorContext newCtx = new TwoFactorContext();
newCtx.setMode(mode);
newCtx.setChannel(normalizedChannel);
newCtx.setRecipient(recipient);
newCtx.setPurpose(mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
newCtx.setStartedAt(LocalDateTime.now());
newCtx.setTtlSeconds(ttl);
newCtx.setAttempts(0);
session.setAttribute(ATTR_CONTEXT, newCtx);
res.setValid(true);
res.setMessage("인증번호를 발송하였습니다.");
res.setTtlSeconds(ttl);
if (properties.isTestNoticeEnabled()) {
res.setTestAuthNumber(authNumber);
}
return res;
}
// =========================================================================
// VERIFY
// =========================================================================
public TwoFactorVerifyResponse verify(HttpServletRequest request, HttpSession session, String code) {
TwoFactorVerifyResponse res = new TwoFactorVerifyResponse();
TwoFactorContext ctx = getActiveContext(session);
if (ctx == null) {
res.setValid(false);
res.setTerminated(true);
res.setMessage("인증 시간이 만료되었습니다. 처음부터 다시 진행해주세요.");
return res;
}
if (ctx.isExpired(LocalDateTime.now())) {
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
res.setValid(false);
res.setTerminated(true);
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
return res;
}
int attempts = ctx.incrementAttempts();
int limit = properties.getAttemptLimit();
try {
authNumberService.verifyAuthNumber(ctx.getRecipient(), code);
} catch (AuthNumberException e) {
AuthNumberException.Reason reason = e.getReason();
if (reason == AuthNumberException.Reason.EXPIRED || reason == AuthNumberException.Reason.NOT_FOUND) {
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
res.setValid(false);
res.setTerminated(true);
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
return res;
}
// 코드 불일치
if (attempts >= limit) {
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_ATTEMPT_EXCEEDED, request);
res.setValid(false);
res.setTerminated(true);
res.setMessage("인증 시도 횟수를 초과했습니다. 처음부터 다시 진행해주세요.");
return res;
}
session.setAttribute(ATTR_CONTEXT, ctx); // attempts 갱신 반영
res.setValid(false);
res.setRemainingAttempts(limit - attempts);
res.setMessage("인증번호가 일치하지 않습니다. (남은 횟수 " + (limit - attempts) + "회)");
return res;
}
// 검증 성공 — 인증번호 즉시 소비(재사용 방지, 2FA 한정)
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
session.removeAttribute(ATTR_CONTEXT);
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
return completeLogin(request, session, res);
}
// STEPUP — 1회용 통과권 발급
issueStepUpPass(session, ctx.getPurpose());
res.setValid(true);
res.setMessage("인증이 완료되었습니다.");
return res;
}
private TwoFactorVerifyResponse completeLogin(HttpServletRequest request, HttpSession session,
TwoFactorVerifyResponse res) {
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
PortalUser user = userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
if (user == null) {
clearPending(session);
res.setValid(false);
res.setTerminated(true);
res.setMessage("로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.");
return res;
}
// 1차 인증~2FA 사이 상태 변경 방어(잠금/차단/승인 취소)
String stateError = revalidateLoginState(user);
if (stateError != null) {
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
LoginFailureReason.ACCOUNT_DISABLED, request.getHeader("User-Agent"));
clearPending(session);
res.setValid(false);
res.setTerminated(true);
res.setMessage(stateError);
return res;
}
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
token.setDetails(authUser);
SecurityContextHolder.getContext().setAuthentication(token);
String redirect = loginFinalizer.finalizeLogin(user, loginId, request, LoginType.TWO_FACTOR);
clearPending(session);
res.setValid(true);
res.setRedirect(redirect);
res.setMessage("인증이 완료되었습니다.");
return res;
}
// =========================================================================
// CANCEL (팝업 닫기 / 타이머 만료)
// =========================================================================
public void cancel(HttpServletRequest request, HttpSession session, String reason) {
TwoFactorContext ctx = getActiveContext(session);
boolean timeout = "TIMEOUT".equalsIgnoreCase(reason);
LoginFailureReason failureReason = timeout
? LoginFailureReason.TWO_FACTOR_TIMEOUT : LoginFailureReason.TWO_FACTOR_CANCELLED;
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason,
request.getHeader("User-Agent"));
}
if (ctx != null && ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
}
session.removeAttribute(ATTR_CONTEXT);
// 로그인 2FA 취소는 로그인 자체를 포기(익명 유지) → pending 제거
if (ctx == null || ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
clearPending(session);
}
}
// =========================================================================
// LOGIN pending 진입 (SuccessHandler 에서 호출)
// =========================================================================
/** 로그인 1차 인증 통과 사용자를 2FA 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
public void beginLoginChallenge(HttpSession session, PortalUser user) {
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
session.removeAttribute(ATTR_CONTEXT);
}
public boolean hasPendingLogin(HttpSession session) {
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
}
// =========================================================================
// STEP-UP 통과권
// =========================================================================
private void issueStepUpPass(HttpSession session, String path) {
session.setAttribute(ATTR_STEPUP_PASS_PATH, path);
session.setAttribute(ATTR_STEPUP_PASS_AT, LocalDateTime.now());
}
/**
* 보호 경로 수정 저장 직후 원경로로 되돌아가는 즉시 왕복(예: {@code /mypage} 수정 →
* {@code redirect:/mypage})에서 중복 step-up 을 막기 위해 통과권을 재발급한다.
*
* <p>경로 고정 + TTL({@link #STEPUP_PASS_TTL_SECONDS}s) 로 <b>1회 왕복만</b> 커버하며,
* 이후 새로 {@code /mypage} 에 진입하면 정상적으로 다시 인증을 요구한다("매번 인증" 유지).</p>
*/
public void grantStepUpPass(HttpSession session, String path) {
if (session != null && path != null) {
issueStepUpPass(session, path);
}
}
/**
* 지정 경로에 대한 유효한 1회용 통과권이 있으면 소비(제거)하고 true 를 반환한다.
* (매번 인증 정책 — 통과권은 즉시 소멸)
*/
public boolean consumeStepUpPass(HttpSession session, String servletPath) {
Object passPath = session.getAttribute(ATTR_STEPUP_PASS_PATH);
Object passAt = session.getAttribute(ATTR_STEPUP_PASS_AT);
if (!(passPath instanceof String) || !(passAt instanceof LocalDateTime)) {
return false;
}
boolean valid = passPath.equals(servletPath)
&& ((LocalDateTime) passAt).plusSeconds(STEPUP_PASS_TTL_SECONDS).isAfter(LocalDateTime.now());
// 매번 인증: 일치/불일치 무관하게 통과권은 이번 판정에서 소멸시킨다.
session.removeAttribute(ATTR_STEPUP_PASS_PATH);
session.removeAttribute(ATTR_STEPUP_PASS_AT);
return valid;
}
// =========================================================================
// 내부 helper
// =========================================================================
private TwoFactorContext.Mode resolveMode(HttpSession session) {
if (session.getAttribute(ATTR_PENDING_USER_ID) != null) {
return TwoFactorContext.Mode.LOGIN;
}
if (SecurityUtil.isAuthenticated()) {
return TwoFactorContext.Mode.STEPUP;
}
return null;
}
private PortalUser resolveTargetUser(HttpSession session, TwoFactorContext.Mode mode) {
if (mode == TwoFactorContext.Mode.LOGIN) {
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
return userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
}
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
if (current == null) {
return null;
}
// 세션 로드 이후 연락처 변경 반영을 위해 DB 재조회
return portalUserRepository.findById(current.getId()).orElse(null);
}
private List<TwoFactorChannel> buildChannels(PortalUser user) {
List<TwoFactorChannel> channels = new ArrayList<>();
if (StringUtils.hasText(user.getEmailAddr())) {
channels.add(new TwoFactorChannel("EMAIL", StringMaskingUtil.maskEmail(user.getEmailAddr())));
}
if (StringUtils.hasText(user.getMobileNumber())) {
channels.add(new TwoFactorChannel("SMS", StringMaskingUtil.maskMobileNumber(user.getMobileNumber())));
}
return channels;
}
private String resolveRecipient(PortalUser user, String channel) {
if ("EMAIL".equals(channel)) {
return StringUtils.hasText(user.getEmailAddr()) ? user.getEmailAddr() : null;
}
if ("SMS".equals(channel)) {
return StringUtils.hasText(user.getMobileNumber())
? PhoneNumberUtil.digitsOnly(user.getMobileNumber()) : null;
}
return null;
}
private TwoFactorContext getActiveContext(HttpSession session) {
Object ctx = session.getAttribute(ATTR_CONTEXT);
if (!(ctx instanceof TwoFactorContext)) {
return null;
}
TwoFactorContext context = (TwoFactorContext) ctx;
if (context.isExpired(LocalDateTime.now())) {
// 만료 컨텍스트는 정리(감사는 verify/cancel 경로에서 처리)
session.removeAttribute(ATTR_CONTEXT);
if (context.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(context.getRecipient());
}
return null;
}
return context;
}
private boolean isSameFlow(TwoFactorContext ctx, TwoFactorContext.Mode mode, String purpose) {
return ctx.getMode() == mode && Objects.equals(ctx.getPurpose(),
mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
}
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
private void discardContext(HttpServletRequest request, HttpSession session, TwoFactorContext ctx) {
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
LoginFailureReason.TWO_FACTOR_CANCELLED, request.getHeader("User-Agent"));
}
if (ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
}
session.removeAttribute(ATTR_CONTEXT);
}
/** 검증 실패로 절차 종료: 인증번호 삭제 + 감사 + 컨텍스트/pending 정리 */
private void terminateWithFailure(HttpSession session, TwoFactorContext ctx,
LoginFailureReason reason, HttpServletRequest request) {
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason,
request.getHeader("User-Agent"));
clearPending(session);
}
if (ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
}
session.removeAttribute(ATTR_CONTEXT);
}
private void clearPending(HttpSession session) {
session.removeAttribute(ATTR_PENDING_USER_ID);
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
}
/** 1차 인증~2FA 사이 계정 상태 재검증. 문제 있으면 사용자 안내 메시지 반환, 정상이면 null */
private String revalidateLoginState(PortalUser user) {
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
return "계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.";
}
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
return "법인 관리자에 의해 비활성화된 계정입니다.";
}
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
return "사용자 승인 대기중입니다.";
}
if (user.getPortalOrg() != null
&& !PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus())) {
return "로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)";
}
return null;
}
}
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.apps.auth.twofactor.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/** 2FA 발송 가능 채널 1건. masked 는 화면 표기용 마스킹 수신처. */
@Data
@AllArgsConstructor
public class TwoFactorChannel {
/** EMAIL | SMS */
private String type;
/** 마스킹된 수신처 (예: te**@ex**.com, 010-12**-34**) */
private String masked;
}
@@ -0,0 +1,24 @@
package com.eactive.apim.portal.apps.auth.twofactor.dto;
import lombok.Data;
import java.util.List;
/** GET /auth/2fa/info 응답. 팝업 초기 렌더용. */
@Data
public class TwoFactorInfoResponse {
/** 컨텍스트 유효 여부(로그인 pending 또는 인증 사용자). false 면 팝업 진입 불가 */
private boolean available;
/** LOGIN | STEPUP */
private String mode;
/** 발송 가능 채널(휴대폰 없으면 이메일만) */
private List<TwoFactorChannel> channels;
/** 인증번호 유효시간(초) — 타이머 초기값 */
private int ttlSeconds;
/** 테스트용 인증번호 노출 여부 */
private boolean testNoticeEnabled;
/** 이미 진행 중인 절차 존재 여부(다른 탭/페이지) */
private boolean inProgress;
/** 진행 중인 절차의 안내 메시지(있으면) */
private String message;
}
@@ -0,0 +1,16 @@
package com.eactive.apim.portal.apps.auth.twofactor.dto;
import lombok.Data;
/** POST /auth/2fa/send 응답. */
@Data
public class TwoFactorSendResponse {
private boolean valid;
private String message;
/** 타이머 유효시간(초) */
private int ttlSeconds;
/** 테스트용 인증번호(테스트 노출 활성 시에만 채워짐) */
private String testAuthNumber;
/** 이미 진행 중인 절차가 있어 발송을 막은 경우 true (confirm 후 force 재요청 유도) */
private boolean inProgress;
}
@@ -0,0 +1,16 @@
package com.eactive.apim.portal.apps.auth.twofactor.dto;
import lombok.Data;
/** POST /auth/2fa/verify 응답. */
@Data
public class TwoFactorVerifyResponse {
private boolean valid;
private String message;
/** LOGIN 모드 성공 시 이동 대상 URL */
private String redirect;
/** 실패 시 남은 시도 횟수 */
private int remainingAttempts;
/** 시도 초과/타임아웃 등으로 절차가 강제 종료되어 재시작이 필요한 경우 true */
private boolean terminated;
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -53,6 +54,14 @@ public class PortalNoticeDTO {
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
private List<TimelineEntryDTO> timeline = Collections.emptyList();
public boolean hasTimeline() {
return timeline != null && !timeline.isEmpty();
}
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
@@ -2,11 +2,18 @@ package com.eactive.apim.portal.apps.community.notice.repository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@EMSDataSource
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
/**
* 게시 중인 공지만 골라 한 번에 읽는다. API Status 카드가 연결 공지 본문을 붙일 때 사용한다.
* 미게시(USE_YN='N')·삭제된 공지는 결과에서 자연히 빠진다.
*/
List<PortalNotice> findByIdInAndUseYn(Collection<String> ids, String useYn);
}
@@ -5,6 +5,9 @@ import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusAssembler;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
@@ -16,8 +19,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -29,6 +34,8 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiStatusAssembler apiStatusAssembler;
private final ApiStatusCatalogService apiStatusCatalogService;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
@@ -67,11 +74,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
@@ -81,10 +90,28 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
List<IncidentAffectedApiDTO> apis = new ArrayList<>();
int hiddenCount = 0;
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId())) {
String publishedName = visibleNames.get(api.getApiId());
if (publishedName == null) {
hiddenCount++;
continue;
}
apis.add(new IncidentAffectedApiDTO(api.getApiId(), publishedName));
}
dto.setAffectedApis(apis);
dto.setHiddenApiCount(hiddenCount);
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
dto.setTimeline(degrading
? apiStatusAssembler.loadTimelines(Collections.singletonList(incident.getIncidentId()))
.getOrDefault(incident.getIncidentId(), Collections.emptyList())
: Collections.emptyList());
}
}
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.apps.community.partnership.controller;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade;
import com.eactive.apim.portal.common.security.WriteRateLimitService;
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import java.io.IOException;
@@ -20,17 +22,20 @@ import javax.validation.Valid;
public class PartnershipApplicationController {
private final PartnershipApplicationFacade partnershipApplicationFacade;
private final WriteRateLimitService writeRateLimitService;
@Autowired
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade){
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade,
WriteRateLimitService writeRateLimitService){
this.partnershipApplicationFacade = partnershipApplicationFacade;
this.writeRateLimitService = writeRateLimitService;
}
@GetMapping
public String newPartnershipApplicationForm(Model model, HttpServletRequest request) {
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?redirect=/partnership";
return "redirect:/login?reason=auth&redirect=/partnership";
}
String referer = request.getHeader("Referer");
@@ -52,6 +57,13 @@ public class PartnershipApplicationController {
return "apps/community/mainPartnershipForm";
}
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
if (writeRateLimitService.isExceeded(WriteTarget.PARTNERSHIP)) {
redirectAttributes.addFlashAttribute("error",
writeRateLimitService.exceededMessage(WriteTarget.PARTNERSHIP));
return "redirect:/partnership";
}
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
// 성공 메시지 추가
@@ -61,4 +73,23 @@ public class PartnershipApplicationController {
return "redirect:/partnership";
}
/**
* 본인이 작성한 피드백/개선요청 1건 삭제.
* 목록(최근 3건)의 삭제 버튼이 항목별 form 을 POST 한다 — 등록과 동일하게 폼 전송 + flash 메시지 방식.
*/
@PostMapping("/{id}/delete")
public String deleteMyPartnershipApplication(@PathVariable String id, RedirectAttributes redirectAttributes) {
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?reason=auth&redirect=/partnership";
}
try {
partnershipApplicationFacade.deleteMyApplication(id);
redirectAttributes.addFlashAttribute("success", "피드백/개선요청이 삭제되었습니다.");
} catch (IllegalArgumentException e) {
redirectAttributes.addFlashAttribute("error", e.getMessage());
}
return "redirect:/partnership";
}
}
@@ -3,9 +3,12 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
@EMSDataSource
@@ -16,4 +19,26 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
/**
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
* 등가 조회가 가능한 이유는 위와 동일하다.
*/
List<PartnershipApplication> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
/**
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
*/
Optional<PartnershipApplication> findByIdAndCreatedBy(String id, String createdBy);
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
@Transactional
long deleteByCreatedBy(String createdBy);
/**
* test-cleanup 전용 — 특정 작성자의 글 중 제목이 지정 접두사로 시작하는 것만 조회한다.
* (bizSubject 는 암호화 컬럼이 아니라 LIKE 조회가 가능하다.)
*/
List<PartnershipApplication> findAllByCreatedByAndBizSubjectStartingWith(String createdBy, String bizSubjectPrefix);
}
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
*/
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
/**
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
*/
void deleteMyApplication(String id);
}
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
@@ -20,6 +20,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@Service
@RequiredArgsConstructor
@@ -29,7 +30,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
private final PartnershipApplicationMapper partnershipApplicationMapper;
private final FileService fileService;
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
private final CommunityAdminNotifier portalAdminNotifier;
private final SwingNotifier swingNotifier;
@Override
@@ -44,6 +45,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
FileTypeContext.setFileType("business");
file = fileService.createFile(partnershipApplicationDTO.getFiles());
} finally {
FileTypeContext.clear();
FileService.clearInternalUserContext();
}
}
@@ -61,7 +63,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
if (writer != null) {
params.put("writerName", writer.getUserName());
}
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
swingNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
@Override
@@ -73,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
return partnershipApplicationMapper.toSummaryDtoList(recent);
}
@Override
public void deleteMyApplication(String id) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null) {
throw new IllegalArgumentException("로그인이 필요합니다.");
}
// id 만으로 조회하지 않고 createdBy 를 함께 걸어 타인 글 삭제를 원천 차단한다.
PartnershipApplication target = partnershipApplicationService
.findOwnedByCreatedBy(id, user.getId())
.orElseThrow(() -> new IllegalArgumentException("삭제할 수 있는 피드백/개선요청이 아닙니다."));
// 첨부파일도 함께 정리한다(관리자 삭제 PortalPartnershipManService.delete 와 동일 처리).
if (StringUtils.isNotBlank(target.getFileId())) {
fileService.deleteFile(target.getFileId());
}
partnershipApplicationService.deletePartnershipApplication(target);
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
}
/**
* 작성자 본인 글 단건 조회. id 와 createdBy 를 함께 조건으로 걸어 타인 글은 조회되지 않는다.
*/
@Transactional(readOnly = true)
public Optional<PartnershipApplication> findOwnedByCreatedBy(String id, String createdBy) {
return partnershipApplicationRepository.findByIdAndCreatedBy(id, createdBy);
}
public void deletePartnershipApplication(PartnershipApplication partnershipApplication) {
partnershipApplicationRepository.delete(partnershipApplication);
}
}
@@ -3,6 +3,8 @@ package com.eactive.apim.portal.apps.community.qna.controller;
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
import com.eactive.apim.portal.common.security.WriteRateLimitService;
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
@@ -37,11 +39,14 @@ public class InquiryController {
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
private final InquiryFacade inquiryFacade;
private final InquiryCommentFacade inquiryCommentFacade;
private final WriteRateLimitService writeRateLimitService;
public InquiryController(InquiryFacade inquiryFacade,
InquiryCommentFacade inquiryCommentFacade) {
InquiryCommentFacade inquiryCommentFacade,
WriteRateLimitService writeRateLimitService) {
this.inquiryFacade = inquiryFacade;
this.inquiryCommentFacade = inquiryCommentFacade;
this.writeRateLimitService = writeRateLimitService;
}
/**
@@ -133,6 +138,13 @@ public class InquiryController {
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
if (writeRateLimitService.isExceeded(WriteTarget.INQUIRY)) {
redirectAttributes.addFlashAttribute("error",
writeRateLimitService.exceededMessage(WriteTarget.INQUIRY));
return REDIRECT_INQUIRY;
}
inquiryFacade.createInquiry(inquiryDTO, image);
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
@@ -3,14 +3,29 @@ package com.eactive.apim.portal.apps.community.qna.repository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
@EMSDataSource
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
@Transactional
long deleteByInquirer_Id(String inquirerId);
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
/**
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/
List<Inquiry> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
}
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import com.eactive.apim.portal.file.service.FileService;
@@ -40,7 +40,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final CommunityAdminNotifier inquiryAdminNotifier;
private final SwingNotifier swingNotifier;
private final FileService fileService;
@Override
@@ -142,7 +142,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("writerName", current.getUserName());
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
}
@Override
@@ -0,0 +1,28 @@
package com.eactive.apim.portal.apps.login.constants;
/**
* 로그인 실패 사유 코드. PTL_USER_LOG.FAILURE_REASON 에 문자열(name())로 저장된다.
*/
public enum LoginFailureReason {
/** 아이디(이메일) 미존재 */
ID_NOT_FOUND,
/** 비밀번호 불일치 */
PASSWORD_MISMATCH,
/** 계정 잠금(5회 실패 등) */
ACCOUNT_LOCKED,
/** 비활성 계정(승인 대기/관리자 차단/법인 미승인) */
ACCOUNT_DISABLED,
/** 세션 인증 오류(중복 로그인 등) */
SESSION_AUTH,
/** 2차 인증 - 인증번호 유효시간 초과 */
TWO_FACTOR_TIMEOUT,
/** 2차 인증 - 인증번호 불일치 */
TWO_FACTOR_CODE_MISMATCH,
/** 2차 인증 - 사용자가 팝업을 닫아 취소 */
TWO_FACTOR_CANCELLED,
/** 2차 인증 - 시도 횟수 초과 */
TWO_FACTOR_ATTEMPT_EXCEEDED,
/** 분류 불가 */
UNKNOWN
}
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.apps.login.constants;
/**
* 로그인 유형 코드. PTL_USER_LOG.LOGIN_TYPE 에 문자열(name())로 저장된다.
*/
public enum LoginType {
/** 일반 로그인 (2FA 미적용) */
NORMAL,
/** 2차 인증을 통과한 로그인 */
TWO_FACTOR,
/** 회원가입 직후 자동 로그인 (2FA 미적용) */
SIGNUP_AUTO
}
@@ -0,0 +1,51 @@
package com.eactive.apim.portal.apps.login.controller;
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;
/**
* 동시 접속(중복 세션) 확인 대기 상태(로그인 2FA off 경로)의 확정/취소 API.
*
* <p>대기 상태는 1차 인증(ID/PW) 성공 후 SuccessHandler 만 세팅하므로, 이 엔드포인트는
* 비밀번호 검증을 통과한 세션에서만 의미가 있다. 모든 POST 는 세션 기반
* CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
*/
@RestController
@RequestMapping("/login/duplicate")
@RequiredArgsConstructor
public class DuplicateLoginController {
private final DuplicateLoginService duplicateLoginService;
/** 기존 접속 해제 확인 → 로그인 확정. 무효(만료/상태 변경) 시 재로그인 안내 */
@PostMapping("/confirm")
public Map<String, Object> confirm(HttpServletRequest request, HttpSession session) {
Map<String, Object> result = new HashMap<>();
String redirect = duplicateLoginService.confirm(request, session);
if (redirect != null) {
result.put("valid", true);
result.put("redirect", redirect);
} else {
result.put("valid", false);
result.put("message", "로그인 확인이 만료되었습니다. 다시 로그인해주세요.");
}
return result;
}
/** 확인 취소 — 로그인 포기(익명 유지) */
@PostMapping("/cancel")
public Map<String, Object> cancel(HttpSession session) {
duplicateLoginService.cancel(session);
Map<String, Object> result = new HashMap<>();
result.put("valid", true);
return result;
}
}
@@ -1,5 +1,7 @@
package com.eactive.apim.portal.apps.login.controller;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
import com.eactive.apim.portal.common.exception.PortalRedirectException;
import com.eactive.apim.portal.common.pagerouter.PageHandler;
import org.apache.commons.lang3.StringUtils;
@@ -22,6 +24,13 @@ import static com.eactive.apim.portal.apps.login.constants.LoginConstants.LOGIN_
@Component("LoginHandler")
public class LoginHandler implements PageHandler {
private final TwoFactorService twoFactorService;
private final DuplicateLoginService duplicateLoginService;
public LoginHandler(TwoFactorService twoFactorService, DuplicateLoginService duplicateLoginService) {
this.twoFactorService = twoFactorService;
this.duplicateLoginService = duplicateLoginService;
}
/**
* 로그인 화면으로 들어간다
@@ -47,6 +56,27 @@ public class LoginHandler implements PageHandler {
session.removeAttribute("loginId");
}
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
boolean twoFactorPending = twoFactorService.hasPendingLogin(session);
model.addAttribute("twoFactorPending", twoFactorPending);
// 동시 접속 안내 — 반드시 1차 인증 통과 후(2FA pending 또는 중복 확인 대기)에만 노출한다.
// - 2FA on: 확인 후 2FA 팝업 진행(취소 시 /auth/2fa/cancel)
// - 2FA off: 확인 후 /login/duplicate/confirm 으로 확정
String pendingLoginId = null;
boolean duplicateConfirmPending = false;
if (twoFactorPending) {
pendingLoginId = (String) session.getAttribute(TwoFactorService.ATTR_PENDING_LOGIN_ID);
} else if (duplicateLoginService.hasPending(session)
&& "1".equals(httpRequest.getParameter("duplicate"))) {
pendingLoginId = duplicateLoginService.pendingLoginId(session);
duplicateConfirmPending = true;
}
model.addAttribute("duplicateConfirmPending", duplicateConfirmPending);
model.addAttribute("duplicateInfo",
pendingLoginId != null ? duplicateLoginService.activeSessionInfo(pendingLoginId) : null);
// 이미 인증된 사용자인지 확인
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && !"anonymousUser".equalsIgnoreCase(authentication.getPrincipal().toString())) {
@@ -0,0 +1,185 @@
package com.eactive.apim.portal.apps.login.service;
import com.eactive.apim.portal.apps.login.constants.LoginType;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 로그인 시 동시 접속(중복 세션) 확인 처리.
*
* <p>중복 확인은 반드시 <b>1차 인증(ID/PW) 성공 후</b>에만 수행한다. 비밀번호 검증 전에
* 노출하면 임의 계정의 접속 여부·IP 가 인증 없이 조회되는 정보 노출이 된다(기존
* {@code /api/session/check-duplicate} 사전 체크 방식의 문제).</p>
*
* <p>두 경로에서 쓰인다:
* <ul>
* <li>로그인 2FA on — 2FA pending 상태의 로그인 페이지가 {@link #activeSessionInfo(String)}
* 로 안내 정보를 내려주고, 확인 후 2FA 팝업으로 진행(취소 시 {@code /auth/2fa/cancel}).</li>
* <li>로그인 2FA off — SuccessHandler 가 확정을 보류하고 {@link #begin} 으로 대기 상태 전환.
* 사용자가 확인하면 {@link #confirm} 이 인증을 확정한다(기존 세션은
* {@link LoginFinalizer#finalizeLogin} 의 forceLogoutOtherSessions 로 해제).</li>
* </ul></p>
*/
@Service
@Transactional
@RequiredArgsConstructor
public class DuplicateLoginService {
/** 동시 접속 확인 대기 - 대상 사용자 id (2FA off 경로) */
public static final String ATTR_PENDING_USER_ID = "DUP_PENDING_USER_ID";
/** 동시 접속 확인 대기 - loginId */
public static final String ATTR_PENDING_LOGIN_ID = "DUP_PENDING_LOGIN_ID";
/** 동시 접속 확인 대기 - 진입 시각 */
public static final String ATTR_PENDING_AT = "DUP_PENDING_AT";
/** 확인 대기 유효시간(초). 초과 시 처음부터 재로그인 */
public static final int PENDING_TTL_SECONDS = 120;
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
private final PortalUserRepository portalUserRepository;
private final PortalUserAuthService portalUserAuthService;
private final LoginFinalizer loginFinalizer;
/** 해당 계정의 활성 세션(다른 곳 접속) 존재 여부 */
@Transactional(readOnly = true)
public boolean hasActiveSession(String loginId) {
return activeSession(loginId).isPresent();
}
/**
* 활성 세션 안내 정보(마스킹 IP·접속 시각). 없으면 null.
* 로그인 페이지 확인 팝업 표시용 — 1차 인증 통과 후에만 호출해야 한다.
*/
@Transactional(readOnly = true)
public Map<String, String> activeSessionInfo(String loginId) {
Optional<UserSession> active = activeSession(loginId);
if (!active.isPresent()) {
return null;
}
Map<String, String> info = new HashMap<>();
info.put("ipAddress", maskIpAddress(active.get().getIpAddress()));
info.put("loginTime", active.get().getLoginTime().format(TIME_FORMATTER));
return info;
}
// =========================================================================
// 2FA off 경로: 확정 보류 → 확인 → 확정
// =========================================================================
/** 1차 인증 성공 사용자를 동시 접속 확인 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
public void begin(HttpSession session, PortalUser user) {
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
session.setAttribute(ATTR_PENDING_AT, LocalDateTime.now());
}
public boolean hasPending(HttpSession session) {
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
}
public String pendingLoginId(HttpSession session) {
return session == null ? null : (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
}
/**
* 동시 접속 확인 후 로그인 확정. 대기 상태가 유효하면 인증을 세팅하고 최종 이동 URL 을
* 반환한다(기존 세션 해제 포함). 무효(만료/상태 변경)면 null — 재로그인 필요.
*/
public String confirm(HttpServletRequest request, HttpSession session) {
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
Object at = session.getAttribute(ATTR_PENDING_AT);
cancel(session); // 1회용 — 성공/실패 무관하게 대기 상태는 소멸
if (userId == null || !(at instanceof LocalDateTime)
|| ((LocalDateTime) at).plusSeconds(PENDING_TTL_SECONDS).isBefore(LocalDateTime.now())) {
return null;
}
PortalUser user = portalUserRepository.findById(userId).orElse(null);
if (user == null || !isLoginStillAllowed(user)) {
return null;
}
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
token.setDetails(authUser);
SecurityContextHolder.getContext().setAuthentication(token);
return loginFinalizer.finalizeLogin(user, loginId, request, LoginType.NORMAL);
}
/** 확인 취소 — 대기 상태 정리(익명 유지) */
public void cancel(HttpSession session) {
session.removeAttribute(ATTR_PENDING_USER_ID);
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
session.removeAttribute(ATTR_PENDING_AT);
}
// =========================================================================
// 내부 helper
// =========================================================================
private Optional<UserSession> activeSession(String loginId) {
if (loginId == null) {
return Optional.empty();
}
return userSessionService.getActiveSession(loginId.toLowerCase());
}
/** 1차 인증~확인 사이 계정 상태 변경 방어 (TwoFactorService.revalidateLoginState 와 동일 기준) */
private boolean isLoginStillAllowed(PortalUser user) {
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
return false;
}
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
return false;
}
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
return false;
}
return user.getPortalOrg() == null
|| PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus());
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환). 예: 192.168.240.178 → 192.168.***.178
*/
private static String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
}
@@ -0,0 +1,300 @@
package com.eactive.apim.portal.apps.login.service;
import com.eactive.apim.portal.apps.login.constants.LoginType;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
import com.eactive.apim.portal.config.PasswordEnforcementPolicy;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.user.entity.UserLog;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
/**
* 로그인 최종 확정(finalize) 처리를 공통화한다.
*
* <p>기존 {@code PortalAuthenticationSuccessHandler} 에 인라인되어 있던 후처리
* (실패카운트 리셋, 감사 성공 기록, 후속 유도 세션 플래그, 초대 확인, 중복로그인 정리,
* 물리 세션 타임아웃, 최종 이동 URL 결정)를 여기로 추출했다.</p>
*
* <p>세 경로가 이 로직을 공유한다:
* <ul>
* <li>일반 로그인 — 2FA off 시 SuccessHandler 가 직접 호출({@link LoginType#NORMAL})</li>
* <li>로그인 2FA 통과 — TwoFactorService 가 호출({@link LoginType#TWO_FACTOR})</li>
* <li>회원가입 자동 로그인 — 가입 컨트롤러가 호출({@link LoginType#SIGNUP_AUTO})</li>
* </ul>
* 최종 이동 URL 을 반환하며, 리다이렉트(HTTP 302)는 호출부 책임이다.</p>
*/
@Service
@Transactional
@RequiredArgsConstructor
public class LoginFinalizer {
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
/**
* 미인증 상태로 보호 페이지 접근 시 저장해 둔 원래 요청 경로(로그인+2FA 완료 후 복귀 대상).
* {@code PortalGlobalExceptionHandler} 가 저장하고 여기서 소비(1회용)한다.
*/
public static final String SESSION_POST_LOGIN_REDIRECT = "postLoginRedirect";
private final PortalUserRepository portalUserRepository;
private final PortalProperties portalProperties;
private final PortalUserLogService userLogService;
private final UserPasswordHistoryRepository passwordHistoryRepository;
private final MessageRequestRepository messageRequestRepository;
private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService;
private final PortalPropertyService portalPropertyService;
private final UserLogRepository userLogRepository;
/**
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
*
* @param user 인증된 사용자
* @param rawUsername 감사/세션 표기에 쓸 사용자 식별자(로그인 폼 입력 원본 또는 loginId)
* @param request 현재 요청(IP/헤더/세션)
* @param loginType 로그인 유형(감사 기록용)
* @return 리다이렉트 대상 URL (contextPath 포함)
*/
public String finalizeLogin(PortalUser user, String rawUsername, HttpServletRequest request, LoginType loginType) {
String normalizedUsername = rawUsername != null ? rawUsername.toLowerCase() : null;
user.setLoginFailureCount(0);
portalUserRepository.save(user);
String ip = request.getRemoteAddr();
String sessionId = request.getSession().getId();
// 다른 기기/브라우저의 활성 세션이 남아 있는 상태의 로그인인지(아래 forceLogoutOtherSessions 로
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
// 화면 표시용 "직전 로그인" 조회 — 이번 로그인 기록(logSuccess) 삽입 전에 조회해야
// 방금 로그인이 아닌 그 이전 성공 로그인을 얻는다.
Optional<UserLog> previousLogin = userLogRepository
.findFirstByLoginIdAndSuccessOrderByLoginTimeDesc(rawUsername, true);
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
duplicate);
String contextPath = request.getContextPath();
HttpSession session = request.getSession();
previousLogin.ifPresent(log -> {
session.setAttribute("previousLoginDate", log.getLoginTime());
session.setAttribute("previousLoginIp", log.getIp());
});
applyPostLoginState(user, session, rawUsername, contextPath);
// 초대 코드 확인 - ROLE_USER만 (메인 페이지에서 팝업으로 표시)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationMobileAndStatus(
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
UserInvitation invitation = pendingInvitation.get();
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
session.setAttribute("pendingInvitation", true);
session.setAttribute("pendingInvitationToken", invitation.getToken());
String orgName = portalOrgRepository.findById(invitation.getOrgId())
.map(PortalOrg::getOrgName)
.orElse("알 수 없는 기관");
session.setAttribute("pendingInvitationOrgName", orgName);
}
}
}
// 법인 사용자(관리자/개발자) 로그인 시, 홈 최초 진입에서 클라이언트 신규 신청 유도 팝업을 1회 노출하도록 마킹한다.
// 실제 클라이언트(승인+요청중) 보유 여부 판정과 1회 소비는 IndexController 가 담당한다.
PortalUserEnums.RoleCode roleCode = user.getRoleCode();
if (roleCode == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|| roleCode == PortalUserEnums.RoleCode.ROLE_CORP_USER) {
session.setAttribute("checkClientRegister", true);
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 물리 세션 타임아웃 10분 고정 (콘솔 override 무관하게 물리=논리 단일화, CSRF 수명 포함)
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
logLoginSuccess(request, session, rawUsername);
String decisionToken = (String) session.getAttribute("decisionToken");
if (decisionToken != null) {
return contextPath + "/signup/decision_process";
}
// 미인증 접근으로 저장해 둔 원래 요청 페이지로 복귀(1회용).
// 단, 강제 유도 흐름(이메일 인증/휴면/비밀번호 변경 — session.redirectUrl 세팅)이 있으면
// 그쪽이 우선이므로 복귀시키지 않고 기본 경로로 보낸다.
String postLoginRedirect = (String) session.getAttribute(SESSION_POST_LOGIN_REDIRECT);
session.removeAttribute(SESSION_POST_LOGIN_REDIRECT);
if (postLoginRedirect != null && session.getAttribute("redirectUrl") == null) {
return contextPath + postLoginRedirect;
}
return contextPath + "/";
}
/** 후속 유도(이메일 인증/휴면/비밀번호 변경) 세션 플래그 세팅 */
private void applyPostLoginState(PortalUser user, HttpSession session, String username, String contextPath) {
if (isEmailVerificationRequired(user)) {
session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요.");
session.setAttribute("emailVerificationRequired", true);
session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email");
} else if (isDormantAccount(user)) {
session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요.");
session.setAttribute("dormantAccount", true);
session.setAttribute("dormantLoginId", username);
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
} else if (isTemporaryPasswordLogin(user)) {
applyPasswordChangeState(session,
"임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.",
contextPath + "/password/change");
} else if (isPasswordChangeRequired(user)) {
applyPasswordChangeState(session,
"비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
contextPath + "/password/change");
} else if (user.getPasswordChangeDate() == null) {
// 완화 정책: 현재 비밀번호 확인 단계를 제거했으므로 새 비밀번호 폼으로 바로 유도한다.
applyPasswordChangeState(session,
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
contextPath + "/password/change");
}
}
/**
* 비밀번호 변경 대상자에게 정책(NONE/PERMISSIVE/ENFORCE)을 적용한다.
*/
private void applyPasswordChangeState(HttpSession session, String message, String redirectUrl) {
PasswordEnforcementPolicy policy = PasswordEnforcementPolicy.from(
portalPropertyService.getOrCreateProperty(
PasswordEnforcementPolicy.PROPERTY_GROUP,
PasswordEnforcementPolicy.PROPERTY_NAME,
PasswordEnforcementPolicy.DEFAULT.name(),
"비밀번호 변경 강제 정책 (NONE|PERMISSIVE|ENFORCE)"));
if (policy == PasswordEnforcementPolicy.NONE) {
return;
}
session.setAttribute("success", message);
session.setAttribute("passwordExpired", true);
session.setAttribute("redirectUrl", redirectUrl);
if (policy == PasswordEnforcementPolicy.ENFORCE) {
session.setAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR, Boolean.TRUE);
}
}
private boolean isPasswordChangeRequired(PortalUser user) {
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
.findTopByUserIdOrderByChangeDateDesc(user.getId());
if (latestHistory.isPresent()) {
LocalDateTime lastChangeDate = latestHistory.get().getChangeDate();
return LocalDateTime.now()
.minusDays(portalProperties.getPasswordExpirationDays())
.isAfter(lastChangeDate);
}
return LocalDateTime.now()
.minusDays(portalProperties.getPasswordExpirationDays())
.isAfter(user.getCreatedDate());
}
private boolean isTemporaryPasswordLogin(PortalUser user) {
Optional<MessageRequest> latestResetRequest = messageRequestRepository.findFirstByEmailAndMessageCodeOrderByRequestDateDesc(
user.getLoginId(), MessageCode.USER_PASSWORD_RESET);
if (latestResetRequest.isPresent()) {
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
.findTopByUserIdOrderByChangeDateDesc(user.getId());
return !latestHistory.isPresent() || latestHistory.get().getChangeDate().isBefore(latestResetRequest.get().getRequestDate());
}
return false;
}
private boolean isDormantAccount(PortalUser user) {
return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
}
private boolean isEmailVerificationRequired(PortalUser user) {
return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus());
}
private void logLoginSuccess(HttpServletRequest request, HttpSession session, String username) {
StringBuilder logMessage = new StringBuilder();
logMessage.append("\n");
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
logMessage.append("USER LOGIN SUCCESS\n");
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
logMessage.append("Login At: ").append(LocalDateTime.now().format(formatter)).append("\n");
logMessage.append("\n");
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
logMessage.append("REQUEST INFORMATION\n");
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
logMessage.append("Query String: ").append(request.getQueryString()).append("\n");
logMessage.append("\n");
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
logMessage.append("REQUEST HEADERS\n");
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
java.util.Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
while (headerValues.hasMoreElements()) {
String headerValue = headerValues.nextElement();
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
}
}
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
sessionLogger.info(logMessage.toString());
}
}
@@ -1,10 +1,14 @@
package com.eactive.apim.portal.apps.main.controller;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.main.service.IndexStatisticsService;
import com.eactive.apim.portal.apps.main.service.MainApiFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.List;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -17,6 +21,7 @@ public class IndexController {
private final MainApiFacade mainApiFacade;
private final IndexStatisticsService indexStatisticsService;
private final AppServiceFacade appServiceFacade;
/**
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
@@ -29,7 +34,7 @@ public class IndexController {
* @return
*/
@GetMapping("/")
public String index(Model model) {
public String index(Model model, HttpSession session) {
List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices();
List<String> hashTags = mainApiFacade.getHashTags();
@@ -39,9 +44,37 @@ public class IndexController {
addAttributesToModel(model, services, hashTags, statistics);
resolveClientRegisterNudge(model, session);
return "apps/main/index";
}
/**
* 법인 사용자(관리자/개발자) 로그인 직후, 클라이언트(승인+요청중)가 하나도 없으면
* 클라이언트 신규 신청 유도 팝업 노출 플래그를 세팅한다.
*
* <p>노출 시점은 {@code LoginFinalizer} 가 로그인 시 세팅한 {@code checkClientRegister}
* 세션 마커로 통제한다. 마커는 홈 최초 진입에서 1회 소비하여, 이후 홈 재방문 시
* 반복 노출되지 않게 한다. (역할 게이팅은 마커 세팅 측에서 이미 수행됨)</p>
*/
private void resolveClientRegisterNudge(Model model, HttpSession session) {
if (session.getAttribute("checkClientRegister") == null) {
return;
}
session.removeAttribute("checkClientRegister"); // 1회 소비
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null || user.getPortalOrg() == null) {
return;
}
boolean hasClient = !appServiceFacade.getApikeyList(user.getPortalOrg()).isEmpty()
|| !appServiceFacade.getPendingApiKeyList(user.getPortalOrg()).isEmpty();
if (!hasClient) {
model.addAttribute("needClientRegister", true);
}
}
private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices, List<String> hashTags, IndexStatisticsDTO statistics) {
model.addAttribute("services", apiServices);
@@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
* 인덱스 페이지 하단 통계 DTO
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
* - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
*/
@Data
@Builder
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
private int activeAppCount;
/**
* API 이용 건수
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총
* API 이용 건수 (월누적)
* 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건
*/
private int totalApiCount;
}
@@ -1,14 +1,20 @@
package com.eactive.apim.portal.apps.main.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
private final PortalOrgRepository portalOrgRepository;
private final CredentialRepository credentialRepository;
private final GwAuthClientRepository gwAuthClientRepository;
private final ApiStatsDayRepository apiStatsDayRepository;
private final ApiStatsHourRepository apiStatsHourRepository;
private final PortalPropertyService portalPropertyService;
// 캐시된 통계 데이터
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
totalApiCount = activeApps.stream()
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
.sum();
// 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건
List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
.filter(id -> id != null && !id.trim().isEmpty())
.collect(Collectors.toList());
if (!clientIds.isEmpty()) {
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
}
}
cachedStatistics = IndexStatisticsDTO.builder()
@@ -125,6 +136,30 @@ public class IndexStatisticsService {
}
}
/**
* 게이트웨이 클라이언트들의 이번 달 1일~오늘 누적 API 호출 건수(TOTAL_CNT 합산).
* 어제까지는 API_STATS_DAY(일별 집계), 오늘은 아직 DAY 미집계이므로 API_STATS_HOUR로 합산한다.
* ({@link com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService#combineDayAndToday}와 동일 패턴)
*/
private long getMonthlyApiCallCount(List<String> clientIds) {
LocalDate today = LocalDate.now();
LocalDate monthStart = today.withDayOfMonth(1);
LocalDate dayEnd = today.minusDays(1);
long total = 0L;
if (!monthStart.isAfter(dayEnd)) {
total += nz(apiStatsDayRepository.findSummary(clientIds, monthStart, dayEnd).getTotalCount());
}
ApiStatisticsSummaryDto todaySummary = apiStatsHourRepository.findSummary(
clientIds, today.atStartOfDay(), today.atTime(LocalTime.MAX));
total += nz(todaySummary.getTotalCount());
return total;
}
private static long nz(Long value) {
return value != null ? value : 0L;
}
/**
* PortalProperty에서 통계 노출 여부 조회
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
@@ -1,30 +1,32 @@
package com.eactive.apim.portal.apps.session.controller;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
* 세션 타이머/유휴 로그아웃 처리용 REST API.
*
* <p>중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
* ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
* {@code DuplicateLoginService} 가 처리한다.</p>
*
* <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
* </ul>
*/
@Slf4j
@@ -33,32 +35,8 @@ import java.util.Optional;
@RequiredArgsConstructor
public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
/**
* 로그인 전 중복 세션 확인
*/
@PostMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/**
* 세션 상태 폴링 (인증 필요)
*/
@@ -110,21 +88,33 @@ public class SessionApiController {
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
* 익명(비로그인) 페이지용 세션 keepalive ping.
* 요청이 기존 세션에 접근하는 것만으로 컨테이너의 세션 비활성 타이머가 리셋되어
* 익명 세션(세션 저장 CSRF 토큰, 회원가입 본인인증 상태 포함)이 유지된다.
* 세션이 없으면 새로 만들지 않는다.
*/
private String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
@GetMapping("/ping")
public ResponseEntity<Void> ping(HttpServletRequest request) {
request.getSession(false);
return ResponseEntity.noContent().build();
}
/**
* 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망).
* 세션 만료로 토큰이 사라진 경우 CsrfFilter가 새 토큰을 생성하고,
* 이 핸들러가 토큰 값을 읽는 시점에 새 세션에 저장된다(LazyCsrfTokenRepository).
* 회원가입 절차는 세션에 본인인증 상태를 들고 있어 토큰 재발급만으로는 복구가 안 되므로
* 로그인 페이지 안전망으로만 사용한다.
*/
@GetMapping("/csrf")
public ResponseEntity<Map<String, String>> csrfToken(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
Map<String, String> result = new HashMap<>();
if (token != null) {
result.put("headerName", token.getHeaderName());
result.put("parameterName", token.getParameterName());
result.put("token", token.getToken());
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
return ResponseEntity.ok(result);
}
}

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