40 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 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
229 changed files with 8590 additions and 12454 deletions
+75 -11
View File
@@ -83,7 +83,10 @@ dependencies {
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1' 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-security'
implementation('org.springframework.boot:spring-boot-starter-cache') implementation('org.springframework.boot:spring-boot-starter-cache')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
@@ -91,33 +94,48 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-jdbc'
developmentOnly 'org.springframework.boot:spring-boot-devtools' 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 group: 'xalan', name: 'xalan', version: '2.7.3'
implementation 'org.hibernate:hibernate-envers:5.6.15.Final' 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 '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 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1' implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1'
implementation 'javax.servlet:javax.servlet-api:4.0.0' implementation 'javax.servlet:javax.servlet-api:4.0.0'
implementation 'org.jasypt:jasypt:1.9.3' implementation 'org.jasypt:jasypt:1.9.3'
implementation 'xerces:xercesImpl:2.12.2' implementation 'xerces:xercesImpl:2.12.2'
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 'org.apache.commons:commons-collections4:4.4'
implementation 'commons-net:commons-net:3.9.0' 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' // exclude group: 'commons-collections', module: 'commons-collections'
} }
implementation 'org.mapstruct:mapstruct:1.5.5.Final' implementation 'org.mapstruct:mapstruct:1.5.5.Final'
// WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환. // jackson 개별 pin 제거: 아래 ext 의 jackson-bom.version 이 전 모듈을 일괄 관리한다.
implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6' // 개별 pin 은 BOM 보다 우선하므로 남겨 두면 BOM 만 올렸을 때 core/annotations/databind 가
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6' // 옛 버전에 고정돼 버전이 어긋난다(실제로 그런 상태였다).
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3' implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
@@ -127,7 +145,10 @@ dependencies {
implementation 'net.bytebuddy:byte-buddy:1.14.5' implementation 'net.bytebuddy:byte-buddy:1.14.5'
// Commons FileUpload (WAS 독립적인 multipart 처리) // 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' implementation 'commons-io:commons-io:2.15.1'
@@ -145,6 +166,49 @@ ext {
springMavenArtifactVersion = '5.3.30' springMavenArtifactVersion = '5.3.30'
encoding = 'UTF-8' encoding = 'UTF-8'
profile = 'local' 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')
} }
+90
View File
@@ -28,4 +28,94 @@
</suppress> </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> </suppressions>
@@ -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. 제정
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
*/ */
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId") @Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
List<String> findClientIdsByOrgId(@Param("orgId") String 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);
} }
@@ -5,19 +5,36 @@ import java.sql.SQLException;
import javax.sql.DataSource; import javax.sql.DataSource;
import org.jboss.logging.Logger;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.spring5.SpringTemplateEngine;
import com.google.common.util.concurrent.Futures;
/** /**
* Readiness probe. * Readiness probe.
* *
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?). * <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
* This endpoint validates both EMS and Gateway datasources via JDBC * This endpoint validates both EMS and Gateway datasources via JDBC
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve * {@code Connection.isValid(timeout)}, plus three libraries that WebLogic's
* requests that depend on the database. * oracle_common bundles at a different version from this app (see weblogic.xml
* {@code prefer-application-packages}): Thymeleaf's {@link SpringTemplateEngine}
* (jackson), jboss-logging, and Guava.
*
* <p>These exist because this controller bypasses view resolution entirely
* (plain {@code @RestController} JSON) — a classpath split between the app's
* copy and WebLogic's bundled copy of a preferred package can throw
* {@link LinkageError}/{@link java.util.ServiceConfigurationError} only on the
* real code paths that touch it, while this probe kept reporting 200. That
* already happened once for jackson (Thymeleaf's dialect/module discovery
* failed on every real page while /health/ready stayed green) — jboss-logging
* and Guava carry the same risk (confirmed version mismatch against
* oracle_common, not yet observed failing in production) so they get the same
* kind of forced-touch check here.
* *
* <p>HTTP 200 + JSON when all checks pass. * <p>HTTP 200 + JSON when all checks pass.
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component * <p>HTTP 503 + JSON when any check fails — body still includes the per-component
@@ -30,24 +47,34 @@ public class ReadinessController {
private final DataSource portalDataSource; private final DataSource portalDataSource;
private final DataSource gatewayDataSource; private final DataSource gatewayDataSource;
private final SpringTemplateEngine templateEngine;
public ReadinessController( public ReadinessController(
@Qualifier("portalDataSource") DataSource portalDataSource, @Qualifier("portalDataSource") DataSource portalDataSource,
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) { @Qualifier("gatewayDataSource") DataSource gatewayDataSource,
SpringTemplateEngine templateEngine) {
this.portalDataSource = portalDataSource; this.portalDataSource = portalDataSource;
this.gatewayDataSource = gatewayDataSource; this.gatewayDataSource = gatewayDataSource;
this.templateEngine = templateEngine;
} }
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> ready() { public ResponseEntity<String> ready() {
String ems = check(portalDataSource); String ems = check(portalDataSource);
String gw = check(gatewayDataSource); String gw = check(gatewayDataSource);
boolean ok = "UP".equals(ems) && "UP".equals(gw); String view = checkTemplateEngine();
String logging = checkJbossLogging();
String guava = checkGuava();
boolean ok = "UP".equals(ems) && "UP".equals(gw) && "UP".equals(view)
&& "UP".equals(logging) && "UP".equals(guava);
String body = "{" String body = "{"
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\"," + "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\"," + "\"ems\":\"" + ems + "\","
+ "\"gateway\":\""+ gw + "\"" + "\"gateway\":\"" + gw + "\","
+ "\"view\":\"" + view + "\","
+ "\"logging\":\"" + logging + "\","
+ "\"guava\":\"" + guava + "\""
+ "}"; + "}";
return ResponseEntity.status(ok ? 200 : 503) return ResponseEntity.status(ok ? 200 : 503)
@@ -55,6 +82,49 @@ public class ReadinessController {
.body(body); .body(body);
} }
/** {@code getConfiguration()} lazily runs Thymeleaf's dialect/module init on first call
* and caches it — so this is cheap once healthy, and reproduces the exact failure path
* a real page render would hit. Catches {@link Throwable}: init failures here have
* surfaced as {@link Error} (ServiceConfigurationError), not just Exception. */
private String checkTemplateEngine() {
try {
templateEngine.getConfiguration();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
/** Forces jboss-logging's {@code Logger} + its bound provider (LogManager/JBoss/JUL
* discovery) to load and actually run a log-level check, not just resolve the class. */
private static String checkJbossLogging() {
try {
Logger log = Logger.getLogger(ReadinessController.class);
log.isDebugEnabled();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
/** {@code Futures.immediateFuture} touches Guava's {@code util.concurrent} machinery
* (AbstractFuture/InternalFutureFailureAccess) — the part backed by the separate
* {@code failureaccess} jar, which is exactly where an app/WebLogic version mismatch
* would split across classloaders. */
private static String checkGuava() {
try {
Futures.immediateFuture(Boolean.TRUE).isDone();
return "UP";
} catch (Throwable t) {
return down(t);
}
}
private static String down(Throwable t) {
String msg = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
}
private static String check(DataSource ds) { private static String check(DataSource ds) {
if (ds == null) return "DOWN:NO_DATASOURCE"; if (ds == null) return "DOWN:NO_DATASOURCE";
try (Connection c = ds.getConnection()) { try (Connection c = ds.getConnection()) {
@@ -36,6 +36,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@@ -155,10 +156,10 @@ public class ApiSpecService {
updateReferences(pathItem, apiId); updateReferences(pathItem, apiId);
String tmpFullPath = StringUtils.isNotEmpty(basePath) String tmpFullPath = StringUtils.isNotEmpty(basePath)
? StringUtils.join(basePath, "/", StringUtils.removeStart(path, "/")) ? StringUtils.join(basePath, "/", Strings.CS.removeStart(path, "/"))
: path; : path;
final String fullPath = StringUtils.replacePattern(tmpFullPath, "//+", "/"); final String fullPath = tmpFullPath.replaceAll("//+", "/");
// HTTP 메소드별로 중복 체크 // HTTP 메소드별로 중복 체크
pathItem.readOperationsMap().forEach((httpMethod, operation) -> { pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
@@ -294,7 +295,7 @@ public class ApiSpecService {
return StringUtils.EMPTY; return StringUtils.EMPTY;
} }
Server server = api.getServers().get(0); 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) { private void mergeComponents(OpenAPI currentAPI, String apiId, String apiName, Map<String, Object> mergedComponents, ObjectMapper objectMapper) {
@@ -41,7 +41,13 @@ public class ApprovalService {
approval.setApprovalType(ApprovalType.APP); approval.setApprovalType(ApprovalType.APP);
approval.setTargetId(request.getId()); approval.setTargetId(request.getId());
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser()); 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()) { for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
this.addApprover(approval, user.getUser(), user.getApprovalOrder()); this.addApprover(approval, user.getUser(), user.getApprovalOrder());
@@ -64,33 +64,36 @@ public class ApiPermissionFilter {
.collect(Collectors.toList()); .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) { String org, String roleCode) {
if (!isAuthenticated) { if (!isAuthenticated) {
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()); return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
} }
boolean orgMatch = org != null && (spec.getDisplayOrg() == null || boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
spec.getDisplayOrg().isEmpty() || || (org != null && spec.getDisplayOrg().contains(org));
spec.getDisplayOrg().contains(org)); boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null || || (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
spec.getDisplayRoleCode().isEmpty() || return orgOk && roleOk;
spec.getDisplayRoleCode().contains(roleCode));
return orgMatch || roleMatch;
} }
private static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated, /** {@link #checkServicePermissions} 와 동일 규칙(중복 유지 — 대상 DTO 타입만 다름). */
static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
String org, String roleCode) { String org, String roleCode) {
if (!isAuthenticated) { if (!isAuthenticated) {
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()); return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
} }
boolean orgMatch = org != null && (spec.getDisplayOrg() == null || boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
spec.getDisplayOrg().isEmpty() || || (org != null && spec.getDisplayOrg().contains(org));
spec.getDisplayOrg().contains(org)); boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null || || (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
spec.getDisplayRoleCode().isEmpty() || return orgOk && roleOk;
spec.getDisplayRoleCode().contains(roleCode));
return orgMatch || roleMatch;
} }
} }
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Service @Service
@@ -104,9 +105,15 @@ public class AuthNumberServiceImpl implements AuthNumberService {
private void validateResendTime(String recipientKey) { private void validateResendTime(String recipientKey) {
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> { storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime) LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
.plusSeconds(resendLimitSeconds).isAfter(now)) { .minusSeconds(authNumberExpirationTime)
throw new AuthNumberException("잠시 후에 다시 시도해 주세요."); .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));
} }
}); });
} }
@@ -1,6 +1,8 @@
package com.eactive.apim.portal.apps.auth.twofactor; package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.user.facade.UserFacade; 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 com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.annotation.Secured;
@@ -11,6 +13,8 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
/** /**
@@ -30,6 +34,7 @@ public class StepUpPasswordController {
private final UserFacade userFacade; private final UserFacade userFacade;
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
@GetMapping("/password") @GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) { public String page(@RequestParam(required = false) String returnUrl, Model model) {
@@ -44,7 +49,8 @@ public class StepUpPasswordController {
@PostMapping("/password") @PostMapping("/password")
public String verify(@RequestParam String currentPassword, public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl, @RequestParam(required = false) String returnUrl,
HttpSession session, Model model) { HttpSession session, HttpServletRequest request, HttpServletResponse response,
Model model) {
String path = pathOf(returnUrl); String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) { if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/"; return "redirect:/";
@@ -52,12 +58,21 @@ public class StepUpPasswordController {
String loginId = SecurityUtil.getCurrentLoginId(); String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) { if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀 // 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
passwordConfirmFailureTracker.reset(session);
twoFactorService.grantStepUpPass(session, path); twoFactorService.grantStepUpPass(session, path);
return "redirect:" + path; return "redirect:" + path;
} }
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다."); // 연속 실패 카운트 증가. 임계치 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다(무차별 대입 방어).
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); model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword"; return "apps/auth/stepupPassword";
} }
@@ -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.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade; 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.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil; import com.eactive.apim.portal.common.util.UserTypeUtil;
import java.io.IOException; import java.io.IOException;
@@ -20,10 +22,13 @@ import javax.validation.Valid;
public class PartnershipApplicationController { public class PartnershipApplicationController {
private final PartnershipApplicationFacade partnershipApplicationFacade; private final PartnershipApplicationFacade partnershipApplicationFacade;
private final WriteRateLimitService writeRateLimitService;
@Autowired @Autowired
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade){ public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade,
WriteRateLimitService writeRateLimitService){
this.partnershipApplicationFacade = partnershipApplicationFacade; this.partnershipApplicationFacade = partnershipApplicationFacade;
this.writeRateLimitService = writeRateLimitService;
} }
@@ -52,6 +57,13 @@ public class PartnershipApplicationController {
return "apps/community/mainPartnershipForm"; return "apps/community/mainPartnershipForm";
} }
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
if (writeRateLimitService.isExceeded(WriteTarget.PARTNERSHIP)) {
redirectAttributes.addFlashAttribute("error",
writeRateLimitService.exceededMessage(WriteTarget.PARTNERSHIP));
return "redirect:/partnership";
}
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO); partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
// 성공 메시지 추가 // 성공 메시지 추가
@@ -61,4 +73,23 @@ public class PartnershipApplicationController {
return "redirect:/partnership"; 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,6 +3,8 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication; import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource; import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List; 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.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -18,7 +20,25 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
*/ */
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy); 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 (평문 등가 조회 가능한 이유는 위와 동일). */ /** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
@Transactional @Transactional
long deleteByCreatedBy(String createdBy); long deleteByCreatedBy(String createdBy);
/**
* test-cleanup 전용 — 특정 작성자의 글 중 제목이 지정 접두사로 시작하는 것만 조회한다.
* (bizSubject 는 암호화 컬럼이 아니라 LIKE 조회가 가능하다.)
*/
List<PartnershipApplication> findAllByCreatedByAndBizSubjectStartingWith(String createdBy, String bizSubjectPrefix);
} }
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록. * 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
*/ */
List<PartnershipApplicationSummaryDTO> getMyRecentApplications(); List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
/**
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
*/
void deleteMyApplication(String id);
} }
@@ -20,6 +20,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -74,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId()); List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
return partnershipApplicationMapper.toSummaryDtoList(recent); 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.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication; import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) { public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(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.InquiryDTO;
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch; 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.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.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil; import com.eactive.apim.portal.common.util.UserTypeUtil;
@@ -37,11 +39,14 @@ public class InquiryController {
public static final String REDIRECT_INQUIRY = "redirect:/inquiry"; public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
private final InquiryFacade inquiryFacade; private final InquiryFacade inquiryFacade;
private final InquiryCommentFacade inquiryCommentFacade; private final InquiryCommentFacade inquiryCommentFacade;
private final WriteRateLimitService writeRateLimitService;
public InquiryController(InquiryFacade inquiryFacade, public InquiryController(InquiryFacade inquiryFacade,
InquiryCommentFacade inquiryCommentFacade) { InquiryCommentFacade inquiryCommentFacade,
WriteRateLimitService writeRateLimitService) {
this.inquiryFacade = inquiryFacade; this.inquiryFacade = inquiryFacade;
this.inquiryCommentFacade = inquiryCommentFacade; this.inquiryCommentFacade = inquiryCommentFacade;
this.writeRateLimitService = writeRateLimitService;
} }
/** /**
@@ -133,6 +138,13 @@ public class InquiryController {
return APPS_COMMUNITY_MAIN_INQUIRY_FORM; 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); inquiryFacade.createInquiry(inquiryDTO, image);
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다."); redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
@@ -3,7 +3,9 @@ package com.eactive.apim.portal.apps.community.qna.repository;
import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.qna.entity.Inquiry; import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.eai.rms.data.EMSDataSource; import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -17,4 +19,13 @@ public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSp
@Transactional @Transactional
long deleteByInquirer_Id(String inquirerId); 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);
} }
@@ -24,6 +24,8 @@ import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryReposito
import com.eactive.apim.portal.template.entity.MessageCode; import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest; import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository; import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.user.entity.UserLog;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -74,6 +76,7 @@ public class LoginFinalizer {
private final PortalOrgRepository portalOrgRepository; private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService; private final UserSessionService userSessionService;
private final PortalPropertyService portalPropertyService; private final PortalPropertyService portalPropertyService;
private final UserLogRepository userLogRepository;
/** /**
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다. * 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
@@ -96,11 +99,19 @@ public class LoginFinalizer {
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로 // 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다. // 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent(); boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
// 화면 표시용 "직전 로그인" 조회 — 이번 로그인 기록(logSuccess) 삽입 전에 조회해야
// 방금 로그인이 아닌 그 이전 성공 로그인을 얻는다.
Optional<UserLog> previousLogin = userLogRepository
.findFirstByLoginIdAndSuccessOrderByLoginTimeDesc(rawUsername, true);
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"), userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
duplicate); duplicate);
String contextPath = request.getContextPath(); String contextPath = request.getContextPath();
HttpSession session = request.getSession(); HttpSession session = request.getSession();
previousLogin.ifPresent(log -> {
session.setAttribute("previousLoginDate", log.getLoginTime());
session.setAttribute("previousLoginIp", log.getIp());
});
applyPostLoginState(user, session, rawUsername, contextPath); applyPostLoginState(user, session, rawUsername, contextPath);
@@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
* 인덱스 페이지 하단 통계 DTO * 인덱스 페이지 하단 통계 DTO
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태) * - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태) * - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태) * - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
*/ */
@Data @Data
@Builder @Builder
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
private int activeAppCount; private int activeAppCount;
/** /**
* API 이용 건수 * API 이용 건수 (월누적)
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총 * 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건
*/ */
private int totalApiCount; private int totalApiCount;
} }
@@ -1,14 +1,20 @@
package com.eactive.apim.portal.apps.main.service; 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.app.repository.CredentialRepository;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO; 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.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus; import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus; import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
private final PortalOrgRepository portalOrgRepository; private final PortalOrgRepository portalOrgRepository;
private final CredentialRepository credentialRepository; private final CredentialRepository credentialRepository;
private final GwAuthClientRepository gwAuthClientRepository;
private final ApiStatsDayRepository apiStatsDayRepository;
private final ApiStatsHourRepository apiStatsHourRepository;
private final PortalPropertyService portalPropertyService; private final PortalPropertyService portalPropertyService;
// 캐시된 통계 데이터 // 캐시된 통계 데이터
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수 // 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds); activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API // 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds); List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
totalApiCount = activeApps.stream() .filter(id -> id != null && !id.trim().isEmpty())
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0) .collect(Collectors.toList());
.sum(); if (!clientIds.isEmpty()) {
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
}
} }
cachedStatistics = IndexStatisticsDTO.builder() 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에서 통계 노출 여부 조회 * PortalProperty에서 통계 노출 여부 조회
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환 * 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.dto.*; import com.eactive.apim.portal.apps.user.dto.*;
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade; import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacade; import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil; import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -55,6 +56,7 @@ public class AccountController {
private final UserSessionService userSessionService; private final UserSessionService userSessionService;
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties; private final TwoFactorProperties twoFactorProperties;
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */ /** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
@@ -65,12 +67,18 @@ public class AccountController {
} }
@PostMapping("/password/confirm") @PostMapping("/password/confirm")
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) { public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
String currentLoginId = SecurityUtil.getCurrentLoginId(); String currentLoginId = SecurityUtil.getCurrentLoginId();
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword); if (userFacade.verifyCurrentPassword(currentLoginId, inputPassword)) {
passwordConfirmFailureTracker.reset(session);
return ResponseEntity.ok(new ValidationResponse(true, "비밀번호가 확인되었습니다."));
}
String message = isPasswordCorrect ? "비밀번호가 확인되었습니다." : "비밀번호가 일치하지 않습니다."; // 무제한 시도 차단 — step-up 확인 페이지와 동일한 카운터/정책을 쓴다.
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message)); PasswordConfirmFailureTracker.Outcome outcome =
passwordConfirmFailureTracker.recordFailure(session, request, response);
return ResponseEntity.ok(new ValidationResponse(false, outcome.getMessage()));
} }
@GetMapping("/password/verify") @GetMapping("/password/verify")
@@ -96,15 +104,29 @@ public class AccountController {
} }
@PostMapping("/password/verify") @PostMapping("/password/verify")
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) { public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes,
HttpSession session, HttpServletRequest request, HttpServletResponse response, Model model) {
String currentLoginId = SecurityUtil.getCurrentLoginId(); String currentLoginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) { if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
passwordConfirmFailureTracker.reset(session);
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO()); model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
// GET /password/change 와 동일하게 2FA 필요 여부를 내려준다.
// 누락되면 폼 JS 가 제출 가로채기(비밀번호 규칙 게이트 + 2FA 팝업)를 등록하지 않아
// 서버가 통과권 없음으로 계속 되돌리고 비밀번호를 바꿀 수 없다.
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
return "apps/mypage/passwordChange"; return "apps/mypage/passwordChange";
} else {
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
return "redirect:/password/verify";
} }
// 무제한 시도 차단 — 임계 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다.
PasswordConfirmFailureTracker.Outcome outcome =
passwordConfirmFailureTracker.recordFailure(session, request, response);
if (outcome.isForcedLogout()) {
return outcome.isAccountLocked()
? "redirect:/login?pwFailExceeded=1&locked=1"
: "redirect:/login?pwFailExceeded=1";
}
redirectAttributes.addFlashAttribute("error", outcome.getMessage());
return "redirect:/password/verify";
} }
@PostMapping("/password/change") @PostMapping("/password/change")
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
} }
@PostMapping("/check_password_match") @PostMapping("/check_password_match")
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) { public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2)); return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
} }
@PostMapping("/register/confirm_password") @PostMapping("/register/confirm_password")
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber") @AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2") @PasswordMatch(input = "password", confirm = "confirmPassword")
@Data @Data
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber") @PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO { public class PortalUserRegistrationDTO {
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
*/ */
private String password; private String password;
private String password2;
@CellPhone @CellPhone
private String mobileNumber; private String mobileNumber;
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
public void withdrawUser(String userId, String withdrawalReason) { public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId); PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한 // 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) { if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){ throw new IllegalArgumentException(
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다."); "법인 관리자는 회원 탈퇴를 할 수 없습니다. "
} + "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.");
} }
// 약관 동의 정보 삭제 // 약관 동의 정보 삭제
@@ -19,7 +19,7 @@ public interface UserRegisterFacade {
ValidationResponse checkPassword(String password, String loginId, String mobileNumber); ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
ValidationResponse checkPasswordMatch(String password, String password2); ValidationResponse checkPasswordMatch(String password, String confirmPassword);
ValidationResponse verifyPassword(String loginId, String confirmPassword); ValidationResponse verifyPassword(String loginId, String confirmPassword);
@@ -94,8 +94,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
} }
@Override @Override
public ValidationResponse checkPasswordMatch(String password, String password2) { public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
boolean isMatch = password.equals(password2); boolean isMatch = password.equals(confirmPassword);
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다."; String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
return new ValidationResponse(isMatch, message); return new ValidationResponse(isMatch, message);
} }
@@ -34,7 +34,8 @@ public class PasswordService {
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다.")); .orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
validatePasswordUpdate(user, newPassword, confirmPassword); validatePasswordUpdate(user, newPassword, confirmPassword);
checkPasswordHistory(user.getLoginId(), newPassword); // PTL_USER_PASSWORD_HISTORY.USER_ID 에는 loginId가 아닌 PortalUser.id가 저장된다.
checkPasswordHistory(user.getId(), newPassword);
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId()); List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
if(histories.isEmpty()) { if(histories.isEmpty()) {
@@ -85,6 +86,17 @@ public class PasswordService {
return result; return result;
} }
/**
* {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로
* 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 <b>덮어쓰기 직전</b>에 호출해,
* 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를
* 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다.
*/
@Transactional
public void recordExternalPasswordChange(String userId, String previousPasswordHash) {
savePasswordHistory(userId, previousPasswordHash);
}
private void checkPasswordHistory(String userId, String newPassword) { private void checkPasswordHistory(String userId, String newPassword) {
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId); List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
@@ -115,26 +127,4 @@ public class PasswordService {
passwordHistoryRepository.save(newHistory); passwordHistoryRepository.save(newHistory);
} }
private boolean isValidPassword(String password) {
if (password.length() < 8) {
return false;
}
boolean hasLetter = false;
boolean hasDigit = false;
boolean hasSpecial = false;
for (char c : password.toCharArray()) {
if (Character.isLetter(c)) {
hasLetter = true;
} else if (Character.isDigit(c)) {
hasDigit = true;
} else if (!Character.isWhitespace(c)) {
hasSpecial = true;
}
}
return hasLetter && hasDigit && hasSpecial;
}
} }
@@ -56,6 +56,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final MessageRequestRepository messageRequestRepository; private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil; private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer; private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@Override @Override
@Transactional(noRollbackFor = UsernameNotFoundException.class) @Transactional(noRollbackFor = UsernameNotFoundException.class)
@@ -66,7 +67,9 @@ public class PortalUserAuthService implements UserDetailsService {
PortalUser portalUser = findByEmailAddr(normalizedUsername); PortalUser portalUser = findByEmailAddr(normalizedUsername);
return buildAuthenticatedUser(portalUser); return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) { } catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."); // 계정 열거(user enumeration) 공격 방지: 비밀번호 불일치(BadCredentialsException, PortalAuthenticationManager)와
// 동일한 문구를 사용해 아이디 존재 여부가 노출되지 않도록 한다.
throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
} }
} }
@@ -162,6 +165,10 @@ public class PortalUserAuthService implements UserDetailsService {
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다.")); .orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
String tempPassword = EncryptionUtil.generateNewPassword(); String tempPassword = EncryptionUtil.generateNewPassword();
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
// 것이 허용되는 보안 허점이 생긴다.
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword)); portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다 // 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기) // (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
@@ -1,53 +1,23 @@
package com.eactive.apim.portal.apps.user.validator; package com.eactive.apim.portal.apps.user.validator;
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.common.validator.PasswordRuleValidator; import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/**
* 비밀번호 문자열 정책 검사 진입점. 규칙 자체는 정본인 {@link PasswordRuleValidator} 가 갖고,
* 여기서는 DB 토글({@link PasswordPolicyProperties})을 얹어 위임만 한다.
*/
@Component @Component
@RequiredArgsConstructor
public class PasswordValidator { public class PasswordValidator {
public boolean isValidPassword(String password) { private final PasswordPolicyProperties passwordPolicyProperties;
if (password == null || password.isEmpty()) {
return false;
}
String tmpPw = password.toUpperCase();
return isValidLengthAndCharacters(tmpPw) && !containsInvalidPatterns(tmpPw);
}
public boolean isValidPassword(String password, String loginId, String mobileNumber) { public boolean isValidPassword(String password, String loginId, String mobileNumber) {
PasswordRuleValidator validator = new PasswordRuleValidator(); PasswordRuleValidator validator = new PasswordRuleValidator();
return validator.isValid(password, loginId, mobileNumber); return validator.isValid(password, loginId, mobileNumber,
} passwordPolicyProperties.isKeyboardSequenceBlocked());
private boolean isValidLengthAndCharacters(String password) {
final int MIN = 8;
final int MAX = 50;
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
return password.matches(REGEX);
}
private boolean containsInvalidPatterns(String password) {
final String SAMEPT = "(\\w)\\1\\1";
final String BLANKPT = "(\\s)";
if (password.matches(BLANKPT) || password.matches(SAMEPT)) {
return true;
}
return containsContinuousCharacters(password);
}
private boolean containsContinuousCharacters(String password) {
for (int i = 0; i < password.length() - 2; i++) {
if (isContinuous(password.charAt(i), password.charAt(i + 1), password.charAt(i + 2))) {
return true;
}
}
return false;
}
private boolean isContinuous(char a, char b, char c) {
return (b - a == 1) && (c - b == 1);
} }
} }
@@ -12,9 +12,11 @@ import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties; import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService; import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.security.ClientGuardService; import com.eactive.apim.portal.common.security.ClientGuardService;
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.djb.footer.RelatedSite; import com.eactive.apim.portal.djb.footer.RelatedSite;
import com.eactive.apim.portal.djb.footer.RelatedSiteService; import com.eactive.apim.portal.djb.footer.RelatedSiteService;
import com.eactive.apim.portal.djb.guide.GuideProperty; import com.eactive.apim.portal.djb.guide.GuideProperty;
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@ControllerAdvice @ControllerAdvice
@@ -32,6 +34,9 @@ public class GlobalControllerAdvice {
@Autowired @Autowired
private ClientGuardService clientGuardService; private ClientGuardService clientGuardService;
@Autowired
private PasswordPolicyProperties passwordPolicyProperties;
@Autowired @Autowired
private PortalPropertyService portalPropertyService; private PortalPropertyService portalPropertyService;
@@ -47,6 +52,9 @@ public class GlobalControllerAdvice {
@Autowired @Autowired
private Environment environment; private Environment environment;
@Autowired
private PasswordCryptoProperties passwordCryptoProperties;
@ModelAttribute("breadcrumb") @ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) { public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI(); String currentPath = request.getRequestURI();
@@ -111,6 +119,26 @@ public class GlobalControllerAdvice {
return clientGuardService.isDevtoolsGuardEnabled(); return clientGuardService.isDevtoolsGuardEnabled();
} }
/**
* 비밀번호 정책 - 키보드 배열 연속 문자 차단 여부.
* PortalProperty(Portal/password.keyboard-sequence.block)에서 조회.
* head 의 window.__PASSWORD_POLICY__ 와 비밀번호 요구사항 체크리스트 노출에 함께 쓴다.
*/
@ModelAttribute("passwordKeyboardSequenceBlock")
public boolean passwordKeyboardSequenceBlock() {
return passwordPolicyProperties.isKeyboardSequenceBlocked();
}
/**
* 비밀번호 전송암호화 사용 여부(yml portal.security.password-encrypt.enabled).
* head 의 window.__PASSWORD_CRYPTO__ 로 내려가 password-crypto.js 가 읽는다.
* 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 화면은 평문으로 폴백한다.
*/
@ModelAttribute("passwordCryptoEnabled")
public boolean passwordCryptoEnabled() {
return passwordCryptoProperties.isEnabled();
}
/** /**
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null). * 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
*/ */
@@ -133,6 +161,61 @@ public class GlobalControllerAdvice {
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처"); "Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
} }
/**
* 메인 페이지 본문에 노출되는 브랜드명. PortalProperty(Portal/brand.name)에서 조회.
* 로고 이미지(alt 텍스트)는 별도이며 이 값의 영향을 받지 않는다.
*/
@ModelAttribute("brandName")
public String brandName() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.name", "DJBank", "메인 페이지 브랜드명 표기");
}
/**
* brandName 뒤에 바로 붙는 주격 조사(이/가). 받침 유무에 따라 관리자가 값을 바꿔도 문법이 깨지지 않도록 계산한다.
*/
@ModelAttribute("brandNameJosaGa")
public String brandNameJosaGa() {
return hasBatchim(brandName()) ? "" : "";
}
/**
* brandName 뒤에 바로 붙는 보조사(은/는).
*/
@ModelAttribute("brandNameJosaEun")
public String brandNameJosaEun() {
return hasBatchim(brandName()) ? "" : "";
}
/**
* 헤더(GNB) 로고 이미지 경로. PortalProperty(Portal/brand.logo.header.path)에서 조회.
*/
@ModelAttribute("brandLogoHeaderPath")
public String brandLogoHeaderPath() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.logo.header.path", "/img/logo/logo-djb.png", "헤더(GNB) 로고 이미지 경로");
}
/**
* 푸터 로고 이미지 경로. PortalProperty(Portal/brand.logo.footer.path)에서 조회.
*/
@ModelAttribute("brandLogoFooterPath")
public String brandLogoFooterPath() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.logo.footer.path", "/img/logo/logo-jjb.png", "푸터 로고 이미지 경로");
}
private boolean hasBatchim(String word) {
if (word == null || word.isEmpty()) {
return false;
}
char last = word.charAt(word.length() - 1);
if (last >= 0xAC00 && last <= 0xD7A3) {
return (last - 0xAC00) % 28 != 0;
}
return "AEIOUaeiou".indexOf(last) < 0;
}
/** /**
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회. * 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
*/ */
@@ -1,8 +1,7 @@
package com.eactive.apim.portal.common.exception; package com.eactive.apim.portal.common.exception;
import java.util.Arrays; import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer; import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
@@ -36,23 +35,27 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PortalGlobalExceptionHandler { public class PortalGlobalExceptionHandler {
private static final String REDIRECT_PREFIX = "redirect:";
private static final String REDIRECT_HOME = REDIRECT_PREFIX + "/";
private static final String REDIRECT_LOGIN_AUTH = REDIRECT_PREFIX + "/login?reason=auth";
private final Logger log = LoggerFactory.getLogger(getClass()); private final Logger log = LoggerFactory.getLogger(getClass());
private final PortalProperties portalProperties; private final PortalProperties portalProperties;
private final Environment environment; private final Environment environment;
@ExceptionHandler(value = NotFoundException.class) @ExceptionHandler(value = NotFoundException.class)
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) { public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
return new ModelAndView("redirect:/"); return new ModelAndView(REDIRECT_HOME);
} }
@ExceptionHandler(value = MethodArgumentTypeMismatchException.class) @ExceptionHandler(value = MethodArgumentTypeMismatchException.class)
public ModelAndView handleMethodArgumentTypeMismatchException(HttpServletRequest request, MethodArgumentTypeMismatchException ex) { public ModelAndView handleMethodArgumentTypeMismatchException(HttpServletRequest request, MethodArgumentTypeMismatchException ex) {
return new ModelAndView("redirect:/"); return new ModelAndView(REDIRECT_HOME);
} }
@ExceptionHandler(value = UserNotLoginException.class) @ExceptionHandler(value = UserNotLoginException.class)
public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) { public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) {
return new ModelAndView("redirect:/login?reason=auth"); return new ModelAndView(REDIRECT_LOGIN_AUTH);
} }
@ExceptionHandler(value = AccessDeniedException.class) @ExceptionHandler(value = AccessDeniedException.class)
@@ -61,7 +64,7 @@ public class PortalGlobalExceptionHandler {
if (!SecurityUtil.isAuthenticated()) { if (!SecurityUtil.isAuthenticated()) {
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다. // 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
savePostLoginRedirect(request); savePostLoginRedirect(request);
return new ModelAndView("redirect:/login?reason=auth"); return new ModelAndView(REDIRECT_LOGIN_AUTH);
} }
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI()); log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
ModelAndView modelAndView = new ModelAndView("error"); ModelAndView modelAndView = new ModelAndView("error");
@@ -122,9 +125,40 @@ public class PortalGlobalExceptionHandler {
|| path.startsWith("/favicon")); || path.startsWith("/favicon"));
} }
/**
* 리다이렉트/뷰 이름으로 허용할 문자. 스킴 구분자(:)·중괄호·달러를 막아
* 외부 URL 이나 Thymeleaf 표현식이 뷰 이름으로 흘러드는 것을 차단한다.
*/
private static final Pattern SAFE_REDIRECT_TARGET = Pattern.compile("[A-Za-z0-9._/?=&%-]+");
@ExceptionHandler(value = PortalRedirectException.class) @ExceptionHandler(value = PortalRedirectException.class)
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) { public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
return new ModelAndView(ex.getMessage()); // PortalRedirectException 은 super(message) 를 호출하지 않으므로 getMessage() 는 항상 null 이다.
// 이전 구현은 그 null 을 뷰 이름으로 넘겨(= 뷰 미지정) 요청 URL 기준으로 뷰가 추론되게 만들었다.
// 실제 대상은 redirectPage 필드다.
String target = ex.getRedirectPage();
if (!isSafeRedirectTarget(target)) {
log.warn("허용되지 않은 리다이렉트 대상 - uri={}, target={}", request.getRequestURI(), target);
return new ModelAndView(REDIRECT_HOME);
}
return new ModelAndView(target);
}
/** 내부 경로/뷰 이름만 허용(외부 URL·프로토콜 상대 URL·표현식 문자 차단) */
private boolean isSafeRedirectTarget(String target) {
if (target == null) {
return false;
}
String path = target;
if (path.startsWith(REDIRECT_PREFIX)) {
path = path.substring(REDIRECT_PREFIX.length());
} else if (path.startsWith("forward:")) {
path = path.substring("forward:".length());
}
if (path.isEmpty() || path.startsWith("//")) { // //evil.example 형태의 외부 리다이렉트 차단
return false;
}
return SAFE_REDIRECT_TARGET.matcher(path).matches();
} }
@ExceptionHandler(value = {IllegalArgumentException.class}) @ExceptionHandler(value = {IllegalArgumentException.class})
@@ -140,7 +174,7 @@ public class PortalGlobalExceptionHandler {
public ModelAndView handleHttpRequestMethodNotSupportedException(HttpServletRequest request, HttpRequestMethodNotSupportedException ex) { public ModelAndView handleHttpRequestMethodNotSupportedException(HttpServletRequest request, HttpRequestMethodNotSupportedException ex) {
log.error(ex.getMessage()); log.error(ex.getMessage());
ModelAndView modelAndView = new ModelAndView(); ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("redirect:/"); modelAndView.setViewName(REDIRECT_HOME);
return modelAndView; return modelAndView;
} }
@@ -159,10 +193,9 @@ public class PortalGlobalExceptionHandler {
*/ */
@ExceptionHandler(value = Exception.class) @ExceptionHandler(value = Exception.class)
public ModelAndView handleException(HttpServletRequest request, Exception ex) { public ModelAndView handleException(HttpServletRequest request, Exception ex) {
String requestParams = request.getParameterMap().entrySet() // 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
.stream() // 이 시점의 파라미터는 이미 복호화된 평문이다.
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue())) String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
.collect(Collectors.joining(", "));
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex); log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
@@ -184,7 +217,7 @@ public class PortalGlobalExceptionHandler {
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) { public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
ModelAndView modelAndView = new ModelAndView(); ModelAndView modelAndView = new ModelAndView();
redirectAttributes.addFlashAttribute("error", ex.getMessage()); redirectAttributes.addFlashAttribute("error", ex.getMessage());
modelAndView.setViewName("redirect:" + request.getRequestURI()); modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
return modelAndView; return modelAndView;
} }
@@ -195,7 +228,7 @@ public class PortalGlobalExceptionHandler {
String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다."; String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다.";
redirectAttributes.addFlashAttribute("error", errorMessage); redirectAttributes.addFlashAttribute("error", errorMessage);
ModelAndView modelAndView = new ModelAndView(); ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("redirect:" + request.getRequestURI()); modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
return modelAndView; return modelAndView;
} }
} }
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.common.exception;
/**
* 작성 요청이 허용 빈도를 초과했을 때 던진다(무제한 요청 차단).
*
* <p>JSON 응답 컨트롤러에서만 사용한다 — 폼 전송 컨트롤러는 예외 대신
* flash 메시지로 되돌려야 하므로 {@code isExceeded} 판정을 직접 쓴다.</p>
*/
public class TooManyWriteRequestsException extends RuntimeException {
public TooManyWriteRequestsException(String message) {
super(message);
}
}
@@ -0,0 +1,52 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.groovy.util.Maps;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 계정 잠금 처리 단일 지점.
*
* <p>로그인 연속 실패(`PortalAuthenticationFailureHandler`)와 본인확인 연속 실패
* (`PasswordConfirmFailureTracker`)가 같은 방식으로 계정을 잠그도록 로직을 모았다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountLockService {
private final PortalUserRepository portalUserRepository;
private final MessageHandlerService messageHandlerService;
/**
* 계정을 잠그고 잠금 알림을 발송한다. 이미 잠긴 계정이면 알림을 중복 발송하지 않는다.
*
* <p>호출자의 트랜잭션에 참여한다 — 로그인 실패 핸들러는 이미 자신만의
* {@code REQUIRES_NEW} 트랜잭션에서 실행되므로 별도 전파를 두면 같은 row 를 두 트랜잭션이
* 잠그게 된다.</p>
*
* @param reason 알림 문구에 실을 잠금 사유
*/
@Transactional
public void lock(PortalUser user, String reason) {
if (user == null) {
return;
}
if ("Y".equals(user.getAccountLockYn())) {
return;
}
user.setAccountLockYn("Y");
portalUserRepository.save(user);
messageHandlerService.publishEvent(MessageCode.USER_ACCOUNT_LOCKED,
MessageRecipient.of(user), Maps.of("reason", reason));
log.warn("계정 잠금 처리 userId={} reason={}", user.getId(), reason);
}
}
@@ -0,0 +1,75 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 중요 페이지 진입용 비밀번호 재인증(본인확인)의 연속 실패 정책을 DB(PortalProperty)에서 조회한다.
*
* <ul>
* <li>{@code password.confirm.failure.limit} - 연속 실패 허용 횟수 (기본 {@value #DEFAULT_LIMIT})</li>
* <li>{@code password.confirm.failure.action} - 임계 초과 시 조치 ({@code LOGOUT} / {@code LOCK})</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PasswordConfirmFailurePolicy {
private static final String GROUP = "Portal";
private static final String NAME_LIMIT = "password.confirm.failure.limit";
private static final String NAME_ACTION = "password.confirm.failure.action";
/** 프로퍼티 미존재/파싱 실패 시 기본 허용 횟수 */
public static final int DEFAULT_LIMIT = 5;
/** 임계 초과 시 조치 */
public enum Action {
/** 세션만 강제 종료 (기본) */
LOGOUT,
/** 계정을 잠근 뒤 세션도 종료 — 재로그인 자체가 차단된다 */
LOCK;
public static final Action DEFAULT = LOGOUT;
public static Action from(String raw) {
if (raw == null || raw.trim().isEmpty()) {
return DEFAULT;
}
try {
return Action.valueOf(raw.trim().toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("{} 값이 유효하지 않음('{}') - 기본값 {} 사용", NAME_ACTION, raw, DEFAULT);
return DEFAULT;
}
}
}
private final PortalPropertyService portalPropertyService;
/** 연속 실패가 이 값 이상이면 {@link #action()} 을 수행한다. */
public int limit() {
String raw = portalPropertyService.getOrCreateProperty(
GROUP, NAME_LIMIT, String.valueOf(DEFAULT_LIMIT),
"본인확인(비밀번호 재인증) 연속 실패 허용 횟수 (이 값 이상 실패 시 조치)");
try {
int parsed = Integer.parseInt(raw.trim());
if (parsed > 0) {
return parsed;
}
log.warn("{} 값이 0 이하({}) - 기본값 {} 사용", NAME_LIMIT, parsed, DEFAULT_LIMIT);
} catch (NumberFormatException e) {
log.warn("{} 값이 숫자가 아님('{}') - 기본값 {} 사용", NAME_LIMIT, raw, DEFAULT_LIMIT);
}
return DEFAULT_LIMIT;
}
/** 임계 초과 시 조치 */
public Action action() {
return Action.from(portalPropertyService.getOrCreateProperty(
GROUP, NAME_ACTION, Action.DEFAULT.name(),
"본인확인 연속 실패 임계 초과 시 조치 (LOGOUT=강제 로그아웃, LOCK=계정 차단 후 로그아웃)"));
}
}
@@ -0,0 +1,131 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.security.PasswordConfirmFailurePolicy.Action;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Optional;
/**
* 중요 페이지 진입 시 비밀번호 재인증(본인확인)의 연속 실패를 추적하고 임계 초과 시 조치한다.
*
* <p>무차별 대입 방어. 카운터는 세션 attribute 로 유지하고, 임계 초과 시
* {@link PasswordConfirmFailurePolicy.Action} 에 따라 강제 로그아웃하거나 계정을 잠근다.
* step-up 확인 페이지·webhook·비밀번호 변경 진입이 모두 이 클래스를 쓴다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PasswordConfirmFailureTracker {
/** 연속 실패 횟수 세션 attribute 키 */
public static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT";
private final PasswordConfirmFailurePolicy policy;
private final UserSessionService userSessionService;
private final AccountLockService accountLockService;
private final PortalUserRepository portalUserRepository;
/** 실패 처리 결과 */
@Getter
public static class Outcome {
private final int failCount;
private final int limit;
private final boolean forcedLogout;
private final boolean accountLocked;
private final String message;
Outcome(int failCount, int limit, boolean forcedLogout, boolean accountLocked, String message) {
this.failCount = failCount;
this.limit = limit;
this.forcedLogout = forcedLogout;
this.accountLocked = accountLocked;
this.message = message;
}
}
/** 확인 성공 시 카운터 초기화 */
public void reset(HttpSession session) {
if (session != null) {
session.removeAttribute(ATTR_FAIL_COUNT);
}
}
/**
* 실패 1회를 기록하고, 임계에 도달했으면 정책에 따라 조치(로그아웃 또는 계정 잠금 후 로그아웃)한다.
* 조치가 수행되면 호출자는 로그인 화면으로 보내기만 하면 된다.
*/
public Outcome recordFailure(HttpSession session, HttpServletRequest request, HttpServletResponse response) {
int limit = policy.limit();
int failCount = increment(session);
if (failCount < limit) {
return new Outcome(failCount, limit, false, false,
policy.action() == Action.LOCK
? "현재 비밀번호가 일치하지 않습니다. 보안을 위해 반복 실패 시 계정 이용이 제한됩니다."
: "현재 비밀번호가 일치하지 않습니다. 보안을 위해 반복 실패 시 자동 로그아웃됩니다.");
}
boolean locked = false;
if (policy.action() == Action.LOCK) {
locked = lockCurrentUser(limit);
}
forceLogout(session, request, response);
log.warn("본인확인 비밀번호 반복 실패로 {} 처리", locked ? "계정 차단" : "강제 로그아웃");
return new Outcome(failCount, limit, true, locked,
locked
? "비밀번호 확인 반복 실패로 계정이 차단되었습니다. 관리자에게 문의해 주세요."
: "비밀번호 확인 반복 실패로 로그아웃되었습니다.");
}
private int increment(HttpSession session) {
Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT);
int next = (count == null ? 0 : count) + 1;
session.setAttribute(ATTR_FAIL_COUNT, next);
return next;
}
/**
* 현재 사용자를 잠근다. 로그인 실패 카운트도 임계치로 맞춰 두어야
* 세션을 새로 받아도 잠금 상태가 이어진다.
*/
private boolean lockCurrentUser(int limit) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
if (current == null) {
return false;
}
Optional<PortalUser> found = portalUserRepository.findById(current.getId());
if (!found.isPresent()) {
return false;
}
PortalUser user = found.get();
if (user.getLoginFailureCount() == null || user.getLoginFailureCount() < limit) {
user.setLoginFailureCount(limit);
portalUserRepository.save(user);
}
accountLockService.lock(user, "본인확인 비밀번호 반복 실패로 인한 계정 차단");
return true;
}
private void forceLogout(HttpSession session, HttpServletRequest request, HttpServletResponse response) {
// SecurityContextLogoutHandler 는 HTTP 세션만 무효화하므로 DB 세션 레코드를 먼저 정리한다.
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
}
}
@@ -0,0 +1,36 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 비밀번호 문자열 정책 중 DB(PortalProperty)로 on/off 하는 항목을 조회한다.
*
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
* 의 자동 생성이 동작하도록 한다.</p>
*
* <ul>
* <li>{@code password.keyboard-sequence.block} - 키보드 배열 연속 문자(qwe/asd 등) 사용 금지</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class PasswordPolicyProperties {
private static final String GROUP = "Portal";
private static final String NAME_KEYBOARD_SEQUENCE = "password.keyboard-sequence.block";
/** 프로퍼티 미존재/파싱 실패 시 기본값 (차단) */
public static final boolean DEFAULT_KEYBOARD_SEQUENCE_BLOCK = true;
private final PortalPropertyService portalPropertyService;
/** 키보드 배열 연속 문자 3자리 이상 사용 금지 여부 */
public boolean isKeyboardSequenceBlocked() {
return Boolean.parseBoolean(
portalPropertyService.getOrCreateProperty(GROUP, NAME_KEYBOARD_SEQUENCE,
String.valueOf(DEFAULT_KEYBOARD_SEQUENCE_BLOCK),
"키보드 배열 연속 문자(qwe·asd 등) 3자리 이상 사용 금지 여부 (true/false)").trim());
}
}
@@ -0,0 +1,49 @@
package com.eactive.apim.portal.common.security;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 기동 시 보안 정책 PTL_PROPERTY 를 미리 생성한다.
*
* <p>PTL_PROPERTY 에는 (GROUP, NAME) 유니크 제약이 없어
* {@code getOrCreateProperty} 최초 조회가 동시 요청으로 경합하면 같은 키가 중복 INSERT 된다
* (중복 시 Hibernate 가 {@code More than one row with the given identifier} 로 실패한다).
* 특히 {@link PasswordPolicyProperties} 는 GlobalControllerAdvice 를 통해 <b>매 요청</b> 조회되므로
* 기동 직후 동시 접속에서 경합할 가능성이 높다.</p>
*
* <p>부팅 완료 시점에 단일 스레드로 한 번 조회해 두면 이후 요청은 항상 기존 행을 읽는다.
* {@code PortalPropertyDuplicateChecker} 보다 먼저 실행되도록 우선순위를 높인다.</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SecurityPolicyPropertySeeder implements ApplicationListener<ApplicationReadyEvent> {
private final PasswordPolicyProperties passwordPolicyProperties;
private final WriteRateLimitPolicy writeRateLimitPolicy;
private final PasswordConfirmFailurePolicy passwordConfirmFailurePolicy;
@Override
@Transactional
public void onApplicationEvent(ApplicationReadyEvent event) {
try {
boolean keyboardSequenceBlock = passwordPolicyProperties.isKeyboardSequenceBlocked();
int writeLimit = writeRateLimitPolicy.perMinuteLimit();
int confirmLimit = passwordConfirmFailurePolicy.limit();
PasswordConfirmFailurePolicy.Action confirmAction = passwordConfirmFailurePolicy.action();
log.info("[보안 정책] keyboard-sequence.block={}, write.rate-limit.per-minute={}, "
+ "password.confirm.failure.limit={}, password.confirm.failure.action={}",
keyboardSequenceBlock, writeLimit, confirmLimit, confirmAction);
} catch (Exception e) {
log.warn("[보안 정책] PTL_PROPERTY 사전 생성 실패 — 최초 요청 시 생성된다", e);
}
}
}
@@ -0,0 +1,46 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 게시물·댓글·피드백 작성의 1분당 허용 건수를 DB(PortalProperty)에서 조회한다.
*
* <p>PTL_PROPERTY (group={@code Portal}, name={@code write.rate-limit.per-minute}) 값으로 제어한다.
* 허용 범위는 {@value #MIN_LIMIT}~{@value #MAX_LIMIT} 이며 벗어나거나 숫자가 아니면 기본값
* {@value #DEFAULT_LIMIT} 로 동작한다. {@code 0} 은 제한 없음을 뜻한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WriteRateLimitPolicy {
private static final String GROUP = "Portal";
private static final String NAME = "write.rate-limit.per-minute";
/** 프로퍼티 미존재/파싱 실패 시 기본 허용 건수 */
public static final int DEFAULT_LIMIT = 1;
private static final int MIN_LIMIT = 0;
private static final int MAX_LIMIT = 10;
private final PortalPropertyService portalPropertyService;
/** 1분당 작성 허용 건수. 0 이면 제한하지 않는다. */
public int perMinuteLimit() {
String raw = portalPropertyService.getOrCreateProperty(
GROUP, NAME, String.valueOf(DEFAULT_LIMIT),
"1분당 게시물·댓글·피드백 작성 허용 건수 (" + MIN_LIMIT + "~" + MAX_LIMIT + ", 0=제한없음)");
try {
int parsed = Integer.parseInt(raw.trim());
if (parsed >= MIN_LIMIT && parsed <= MAX_LIMIT) {
return parsed;
}
log.warn("{} 값이 허용 범위({}~{}) 밖({}) - 기본값 {} 사용", NAME, MIN_LIMIT, MAX_LIMIT, parsed, DEFAULT_LIMIT);
} catch (NumberFormatException e) {
log.warn("{} 값이 숫자가 아님('{}') - 기본값 {} 사용", NAME, raw, DEFAULT_LIMIT);
}
return DEFAULT_LIMIT;
}
}
@@ -0,0 +1,119 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.common.entity.Auditable;
import com.eactive.apim.portal.common.exception.TooManyWriteRequestsException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
/**
* 작성 요청 빈도 제한(무제한 요청 차단).
*
* <p>카운터는 대상별로 독립이며 저장소는 DB 다 — 세션 재발급·다중 탭으로 우회할 수 없다.
* 허용 건수는 {@link WriteRateLimitPolicy} 가 PortalProperty 에서 읽는다.</p>
*
* <p>판정은 "최신 N건을 뽑아 N번째가 1분 이내인가" 로 한다. {@code createdDate} 는
* {@code LocalDateTimeToStringConverter} 로 문자열 저장되므로 쿼리에서 범위 비교하지 않고
* Java 에서 비교한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WriteRateLimitService {
/** 빈도 판정 기준 구간 */
private static final Duration WINDOW = Duration.ofMinutes(1);
/** 빈도 제한 대상. 대상별로 카운터가 독립이다. */
public enum WriteTarget {
INQUIRY("Q&A 문의"),
INQUIRY_COMMENT("댓글"),
PARTNERSHIP("피드백/개선요청");
private final String label;
WriteTarget(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
private final WriteRateLimitPolicy writeRateLimitPolicy;
private final InquiryRepository inquiryRepository;
private final InquiryCommentRepository inquiryCommentRepository;
private final PartnershipApplicationRepository partnershipApplicationRepository;
/** 현재 로그인 사용자 기준으로 허용 빈도를 넘었는지 */
public boolean isExceeded(WriteTarget target) {
return isExceeded(target, currentUserId());
}
/** 허용 빈도를 넘었는지. userId 가 없으면(비인증) 판정하지 않는다. */
@Transactional(readOnly = true)
public boolean isExceeded(WriteTarget target, String userId) {
int limit = writeRateLimitPolicy.perMinuteLimit();
if (limit <= 0 || userId == null || userId.isEmpty()) {
return false;
}
List<? extends Auditable> recent = findRecent(target, userId, limit);
if (recent.size() < limit) {
return false;
}
LocalDateTime oldestOfWindow = recent.get(limit - 1).getCreatedDate();
boolean exceeded = oldestOfWindow != null && oldestOfWindow.isAfter(LocalDateTime.now().minus(WINDOW));
if (exceeded) {
log.warn("작성 빈도 제한 초과 target={} limit={}/분", target, limit);
}
return exceeded;
}
/** 허용 빈도를 넘었으면 예외. JSON 응답 컨트롤러 전용. */
public void assertNotExceeded(WriteTarget target) {
if (isExceeded(target)) {
throw new TooManyWriteRequestsException(exceededMessage(target));
}
}
/** 사용자에게 보여줄 차단 안내 문구 */
public String exceededMessage(WriteTarget target) {
return target.getLabel() + " 작성은 1분에 " + writeRateLimitPolicy.perMinuteLimit()
+ "건까지 가능합니다. 잠시 후 다시 시도해 주세요.";
}
/** 감사 필드 createdBy 와 동일한 식별자(PortalUser.id). 비인증이면 null. */
private static String currentUserId() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return user == null ? null : user.getId();
}
private List<? extends Auditable> findRecent(WriteTarget target, String userId, int limit) {
PageRequest page = PageRequest.of(0, limit);
switch (target) {
case INQUIRY:
return inquiryRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
case INQUIRY_COMMENT:
return inquiryCommentRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
case PARTNERSHIP:
return partnershipApplicationRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
default:
return Collections.emptyList();
}
}
}
@@ -0,0 +1,148 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.security.PrivateKey;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* 봉투(ENC1) 형식으로 들어온 파라미터 값을 평문으로 되돌리는 요청 래퍼.
*
* <p><b>지연 복호화가 필수다.</b> 생성자에서 {@code getParameterMap()} 을 부르면 컨테이너가 요청 본문을
* 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
*
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 1})보다 <b>앞</b>에서 이 래퍼가 씌워지므로,
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든 비밀번호의
* 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
*/
@Slf4j
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
private final PasswordCryptoProperties properties;
/** 봉투 원문 → 평문. 같은 값이 여러 번 조회돼도 RSA 연산은 한 번만 한다. */
private final Map<String, String> decrypted = new HashMap<>();
/** 요청 처리 후 폐기할 keyId(1회용 키 모드). */
private final Set<String> usedKeyIds = new LinkedHashSet<>();
private Map<String, String[]> parameterMapCache;
public DecryptingRequestWrapper(HttpServletRequest request,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore,
PasswordCryptoProperties properties) {
super(request);
this.codec = codec;
this.keyStore = keyStore;
this.properties = properties;
}
@Override
public String getParameter(String name) {
return convert(name, super.getParameter(name));
}
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values == null) {
return null;
}
String[] converted = new String[values.length];
for (int i = 0; i < values.length; i++) {
converted[i] = convert(name, values[i]);
}
return converted;
}
@Override
public Map<String, String[]> getParameterMap() {
if (parameterMapCache == null) {
Map<String, String[]> source = super.getParameterMap();
Map<String, String[]> result = new HashMap<>(Math.max(16, source.size() * 2));
for (Map.Entry<String, String[]> entry : source.entrySet()) {
String name = entry.getKey();
String[] values = entry.getValue();
String[] converted = new String[values.length];
for (int i = 0; i < values.length; i++) {
converted[i] = convert(name, values[i]);
}
result.put(name, converted);
}
parameterMapCache = Collections.unmodifiableMap(result);
}
return parameterMapCache;
}
/** 요청 처리가 끝난 뒤 필터가 호출한다. 1회용 키를 폐기해 재전송을 막는다. */
void consumeUsedKeys() {
for (String keyId : usedKeyIds) {
keyStore.consume(keyId, (HttpServletRequest) getRequest());
}
usedKeyIds.clear();
}
private String convert(String name, String value) {
if (value == null || value.isEmpty()) {
return value;
}
if (!codec.isEnvelope(value)) {
// strict 모드에서는 평문 비밀번호를 받아들이지 않는다. 예외를 던지면 로그인 경로가 500 이 되므로
// 빈 값으로 바꿔 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.
if (properties.isStrict() && PasswordParamNames.isPasswordLike(name)) {
log.warn("암호화되지 않은 비밀번호 파라미터 거부(strict) - uri={}, param={}", getRequestURI(), name);
return "";
}
return value;
}
String cached = decrypted.get(value);
if (cached != null) {
return cached;
}
String keyId = codec.keyIdOf(value);
if (keyId == null) {
log.warn("봉투 형식 오류 - uri={}, param={}", getRequestURI(), name);
return failed(value);
}
PrivateKey privateKey = keyStore.resolve(keyId, (HttpServletRequest) getRequest());
if (privateKey == null) {
log.warn("전송암호화 키를 찾을 수 없음(만료·인스턴스 불일치) - uri={}, param={}, keyId={}",
getRequestURI(), name, keyId);
return failed(value);
}
try {
String plain = codec.decrypt(value, privateKey);
decrypted.put(value, plain);
usedKeyIds.add(keyId);
return plain;
} catch (PasswordDecryptException e) {
log.warn("전송암호화 복호화 실패 - uri={}, param={}, keyId={}, reason={}",
getRequestURI(), name, keyId, e.getMessage());
usedKeyIds.add(keyId);
return failed(value);
}
}
/**
* 복호화 실패 시의 값. 봉투 문자열을 그대로 흘려보내면 비밀번호 정책 검증 등이 엉뚱하게 통과할 수 있어
* 빈 값으로 바꾼다. 결과적으로 사용자에게는 일반적인 입력값 오류로 보인다.
*/
private String failed(String rawValue) {
decrypted.put(rawValue, "");
return "";
}
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 클라이언트에 내려줄 공개키 1건. {@code PasswordKeyController} 응답 본문이기도 하다.
*/
@Data
@AllArgsConstructor
public class IssuedKey {
/** 봉투(ENC1)의 두 번째 세그먼트로 되돌아오는 키 식별자. */
private final String keyId;
/** X.509 SubjectPublicKeyInfo(SPKI) DER 을 표준 base64 로 인코딩한 값. */
private final String publicKey;
/** 남은 수명(초). 클라이언트 캐시 판단용. */
private final int expiresIn;
}
@@ -0,0 +1,52 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
*
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.
* 로컬 개발환경은 HTTP(비 secure context)라 브라우저 {@code crypto.subtle} 을 쓸 수 없으므로
* {@link #enabled} 를 꺼둔다. 켜져 있어도 브라우저가 지원하지 못하면 클라이언트가 평문으로 폴백한다.</p>
*
* <p><b>이 기능은 XSS 방어가 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있다.
* "전송 페이로드 평문 노출" 점검 지적에 대한 대응 범위로만 이해할 것.</p>
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal.security.password-encrypt")
public class PasswordCryptoProperties {
/**
* RSA 개인키 보관 범위.
*
* <ul>
* <li>{@code REQUEST} - 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기. 재전송 공격 차단이 가장 강하다.</li>
* <li>{@code SESSION} - 세션 단위로 키를 보관. 세션 복제 환경에 유리하다.</li>
* <li>{@code SERVER} - 서버 고정 키쌍 + TTL 로테이션. 무상태라 가장 단순하지만 재전송 방지 수단이 없다.</li>
* </ul>
*/
public enum KeyScope {
REQUEST, SESSION, SERVER
}
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
private boolean enabled = false;
/** 키 보관 범위. */
private KeyScope keyScope = KeyScope.REQUEST;
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
private int keyTtlSeconds = 300;
/**
* true 면 서버가 평문 비밀번호 파라미터를 거부한다(빈 값으로 치환 → 인증 실패).
* JS 비활성 사용자나 구형 브라우저가 로그인하지 못하게 되므로 안정화 후에만 켠다.
*/
private boolean strict = false;
/** RSA 키 길이. */
private int rsaKeySize = 2048;
}
@@ -0,0 +1,18 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 봉투 복호화 실패. 화면으로 스택을 올리지 않고 필터에서 흡수한다.
* 메시지에 평문이나 키 자료를 담지 않는다.
*/
public class PasswordDecryptException extends RuntimeException {
private static final long serialVersionUID = 1L;
public PasswordDecryptException(String message) {
super(message);
}
public PasswordDecryptException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,61 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
*
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE} 로 등록해
* Lucy XSS 필터({@code MIN_VALUE + 1})보다 먼저 실행된다. 자세한 이유는
* {@link DecryptingRequestWrapper} 주석 참고.</p>
*
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</p>
*/
@Component
public class PasswordDecryptFilter implements Filter {
private final PasswordCryptoProperties properties;
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
public PasswordDecryptFilter(PasswordCryptoProperties properties,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore) {
this.properties = properties;
this.codec = codec;
this.keyStore = keyStore;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!properties.isEnabled() || !(request instanceof HttpServletRequest)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (!"POST".equalsIgnoreCase(httpRequest.getMethod())) {
chain.doFilter(request, response);
return;
}
DecryptingRequestWrapper wrapper =
new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
try {
chain.doFilter(wrapper, response);
} finally {
wrapper.consumeUsedKeys();
}
}
}
@@ -0,0 +1,104 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.PrivateKey;
import java.security.spec.MGF1ParameterSpec;
import java.util.Base64;
/**
* 클라이언트가 만든 봉투(envelope) 문자열의 파싱·복호화.
*
* <pre>
* ENC1.&lt;keyId&gt;.&lt;b64url(RSA-OAEP(AES키))&gt;.&lt;b64url(iv 12B)&gt;.&lt;b64url(AES-GCM 암호문+태그)&gt;
* </pre>
*
* <p>세그먼트는 base64<b>url</b>(패딩 없음)이라 Lucy XSS 이스케이프와 urlencode 를 모두 통과해도
* 값이 변형되지 않는다.</p>
*
* <p>RSA-OAEP 는 반드시 MGF1 해시까지 SHA-256 으로 지정해야 한다. SunJCE 는
* {@code OAEPWithSHA-256AndMGF1Padding} 만 지정하면 MGF1 에 SHA-1 을 쓰는데,
* 브라우저 Web Crypto 의 {@code RSA-OAEP + SHA-256} 은 MGF1 도 SHA-256 이라 그대로 두면
* 복호화가 실패한다.</p>
*/
@Component
public class PasswordEnvelopeCodec {
public static final String PREFIX = "ENC1.";
/** AES-GCM 인증 태그 길이(비트). Web Crypto 기본값과 동일. */
private static final int GCM_TAG_BITS = 128;
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
/** 값이 봉투 형식인지. 파싱 비용 없이 접두사만 본다. */
public boolean isEnvelope(String value) {
return value != null && value.startsWith(PREFIX);
}
/** 봉투에서 keyId 만 추출. 형식이 어긋나면 {@code null}. */
public String keyIdOf(String value) {
String[] parts = split(value);
return parts == null ? null : parts[1];
}
/**
* RSA 개인키로 AES 키를 풀고 AES-GCM 으로 본문을 복호화한다.
*
* @throws PasswordDecryptException 형식 오류·키 불일치·태그 검증 실패
*/
public String decrypt(String value, PrivateKey privateKey) {
String[] parts = split(value);
if (parts == null) {
throw new PasswordDecryptException("봉투 형식이 올바르지 않다");
}
try {
Key aesKey = unwrapAesKey(URL_DECODER.decode(parts[2]), privateKey);
byte[] iv = URL_DECODER.decode(parts[3]);
byte[] cipherText = URL_DECODER.decode(parts[4]);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
} catch (PasswordDecryptException e) {
throw e;
} catch (Exception e) {
throw new PasswordDecryptException("봉투 복호화 실패", e);
}
}
private Key unwrapAesKey(byte[] wrapped, PrivateKey privateKey) {
try {
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsa.init(Cipher.DECRYPT_MODE, privateKey, new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
return new SecretKeySpec(rsa.doFinal(wrapped), "AES");
} catch (Exception e) {
throw new PasswordDecryptException("AES 키 언랩 실패", e);
}
}
/** {@code ENC1.keyId.encKey.iv.cipher} 5개 세그먼트. 어긋나면 null. */
private String[] split(String value) {
if (!isEnvelope(value)) {
return null;
}
String[] parts = value.split("\\.", 5);
if (parts.length != 5) {
return null;
}
for (String part : parts) {
if (part.isEmpty()) {
return null;
}
}
return parts;
}
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.Map;
/**
* 비밀번호 전송암호화용 공개키 발급.
*
* <p>경로를 {@code /api/**} 아래에 둔 이유: {@code PortalConfigWebDispatcherServlet#addInterceptors}
* 가 {@code /api/**} 를 인터셉터(비밀번호 변경 강제·step-up 가드)에서 제외한다. 로그인 전 익명 상태에서
* 호출되므로 {@code /api/session/csrf} 와 같은 자리에 있어야 리다이렉트에 걸리지 않는다.</p>
*
* <p>GET 이라 CSRF 대상이 아니고, 공개키만 나가므로 인증도 요구하지 않는다.</p>
*/
@RestController
@RequestMapping("/api/security")
public class PasswordKeyController {
private final PasswordCryptoProperties properties;
private final PasswordKeyStore keyStore;
public PasswordKeyController(PasswordCryptoProperties properties, PasswordKeyStore keyStore) {
this.properties = properties;
this.keyStore = keyStore;
}
@GetMapping("/password-key.json")
public ResponseEntity<Map<String, Object>> issue(HttpServletRequest request) {
if (!properties.isEnabled()) {
// 클라이언트가 조용히 평문 폴백하도록 200 + enabled:false 로 답한다.
return noStore().body(Collections.<String, Object>singletonMap("enabled", Boolean.FALSE));
}
IssuedKey key = keyStore.issue(request);
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("enabled", Boolean.TRUE);
body.put("alg", "RSA-OAEP-256");
body.put("keyId", key.getKeyId());
body.put("publicKey", key.getPublicKey());
body.put("expiresIn", key.getExpiresIn());
// REQUEST 는 1회용이라 클라이언트가 캐시하면 안 된다. 나머지는 만료까지 재사용해
// 비밀번호 검증용 ajax 가 호출마다 RSA 키쌍을 만들게 하지 않는다.
body.put("keyScope", properties.getKeyScope().name());
return noStore().body(body);
}
private ResponseEntity.BodyBuilder noStore() {
return ResponseEntity.ok().cacheControl(CacheControl.noStore());
}
}
@@ -0,0 +1,52 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.UUID;
/**
* RSA 키쌍 생성/인코딩 헬퍼. 키 보관소 구현들이 공유한다.
*/
final class PasswordKeyPairs {
private PasswordKeyPairs() {
}
static KeyPair generate(int keySize) {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(keySize);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("RSA 키쌍 생성 실패", e);
}
}
static String newKeyId() {
return UUID.randomUUID().toString().replace("-", "");
}
/** 브라우저 {@code crypto.subtle.importKey('spki', ...)} 가 그대로 먹는 형식. */
static String toSpkiBase64(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
/**
* PKCS#8 바이트 → PrivateKey. 세션 복제(직렬화) 환경을 고려해
* {@link SessionScopedPasswordKeyStore} 는 키 객체 대신 바이트를 보관한다.
*/
static PrivateKey toPrivateKey(byte[] pkcs8) {
try {
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("RSA 개인키 복원 실패", e);
}
}
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import javax.servlet.http.HttpServletRequest;
import java.security.PrivateKey;
/**
* 비밀번호 전송암호화용 RSA 키쌍 보관소. 구현체는 {@link PasswordCryptoProperties.KeyScope} 별로 존재하며
* {@link PasswordKeyStoreRouter} 가 설정값에 따라 위임한다.
*/
public interface PasswordKeyStore {
/** 새 공개키를 발급한다(구현에 따라 기존 키 재사용). */
IssuedKey issue(HttpServletRequest request);
/** keyId 에 대응하는 개인키. 없거나 만료됐으면 {@code null}. */
PrivateKey resolve(String keyId, HttpServletRequest request);
/** 요청 처리가 끝난 뒤 호출. 1회용 키를 폐기하는 구현에서만 의미가 있다. */
void consume(String keyId, HttpServletRequest request);
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.PrivateKey;
/**
* {@code portal.security.password-encrypt.key-scope} 설정값에 따라 실제 보관소로 위임한다.
* 설정은 yml 이므로 기동 시점에 고정된다(런타임 변경 시 재기동 필요).
*/
@Primary
@Component
public class PasswordKeyStoreRouter implements PasswordKeyStore {
private final PasswordCryptoProperties properties;
private final RequestScopedPasswordKeyStore requestScoped;
private final SessionScopedPasswordKeyStore sessionScoped;
private final ServerScopedPasswordKeyStore serverScoped;
public PasswordKeyStoreRouter(PasswordCryptoProperties properties,
RequestScopedPasswordKeyStore requestScoped,
SessionScopedPasswordKeyStore sessionScoped,
ServerScopedPasswordKeyStore serverScoped) {
this.properties = properties;
this.requestScoped = requestScoped;
this.sessionScoped = sessionScoped;
this.serverScoped = serverScoped;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
return delegate().issue(request);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
return delegate().resolve(keyId, request);
}
@Override
public void consume(String keyId, HttpServletRequest request) {
delegate().consume(keyId, request);
}
private PasswordKeyStore delegate() {
PasswordCryptoProperties.KeyScope scope = properties.getKeyScope();
if (scope == PasswordCryptoProperties.KeyScope.SESSION) {
return sessionScoped;
}
if (scope == PasswordCryptoProperties.KeyScope.SERVER) {
return serverScoped;
}
return requestScoped;
}
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 비밀번호 계열 파라미터명 판별. {@code strict} 모드에서 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
*
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
* 향후 축약형이 생길 것을 대비해 {@code passwd}/{@code pwd} 도 함께 본다.</p>
*/
final class PasswordParamNames {
private PasswordParamNames() {
}
static boolean isPasswordLike(String name) {
if (name == null) {
return false;
}
String lower = name.toLowerCase();
return lower.contains("password") || lower.contains("passwd") || lower.contains("pwd");
}
}
@@ -0,0 +1,90 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기하는 보관소({@code key-scope: REQUEST}).
*
* <p>같은 keyId 로 두 번 복호화할 수 없으므로 재전송(replay) 공격이 차단된다. 개인키가 인스턴스
* 로컬 메모리에만 있으므로 WebLogic 다중 인스턴스에서는 스티키 세션이 전제다(현재 CSRF·단일세션
* 강제가 이미 세션 고정을 전제한다).</p>
*/
@Slf4j
@Component
public class RequestScopedPasswordKeyStore implements PasswordKeyStore {
/** 메모리 폭주 방지 상한. 초과 시 만료 스윕 후에도 남으면 발급을 거절하지 않고 가장 오래된 것부터 버린다. */
private static final int MAX_ENTRIES = 20_000;
private final PasswordCryptoProperties properties;
private final Map<String, Entry> entries = new ConcurrentHashMap<>();
public RequestScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
sweep();
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
String keyId = PasswordKeyPairs.newKeyId();
int ttl = properties.getKeyTtlSeconds();
entries.put(keyId, new Entry(keyPair.getPrivate(), System.currentTimeMillis() + ttl * 1000L));
return new IssuedKey(keyId, PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()), ttl);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
Entry entry = entries.get(keyId);
if (entry == null) {
return null;
}
if (entry.expiresAt < System.currentTimeMillis()) {
entries.remove(keyId);
return null;
}
return entry.privateKey;
}
@Override
public void consume(String keyId, HttpServletRequest request) {
entries.remove(keyId);
}
/** 만료 항목 정리. 발급 시점에만 돌리므로 별도 스케줄러가 필요 없다. */
private void sweep() {
long now = System.currentTimeMillis();
Iterator<Map.Entry<String, Entry>> it = entries.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().expiresAt < now) {
it.remove();
}
}
if (entries.size() >= MAX_ENTRIES) {
log.warn("비밀번호 전송암호화 키 보관소 상한 초과 - size={}, 오래된 항목을 버린다", entries.size());
Iterator<Map.Entry<String, Entry>> overflow = entries.entrySet().iterator();
while (overflow.hasNext() && entries.size() >= MAX_ENTRIES) {
overflow.next();
overflow.remove();
}
}
}
private static final class Entry {
private final PrivateKey privateKey;
private final long expiresAt;
private Entry(PrivateKey privateKey, long expiresAt) {
this.privateKey = privateKey;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.KeyPair;
import java.security.PrivateKey;
/**
* 서버 고정 키쌍 + TTL 로테이션 구현({@code key-scope: SERVER}).
*
* <p>직전 키를 1개 유예 보관해, 로테이션 순간에 이미 공개키를 받아 간 폼이 제출돼도 복호화된다.
* 재전송 방지 수단은 없으므로(같은 봉투를 여러 번 보내도 복호화됨) 인증 실패횟수 제한·계정 잠금에
* 의존한다. 무상태라 다중 인스턴스에서도 각자 동작하지만, 인스턴스마다 키가 달라
* 공개키 발급과 폼 제출이 같은 인스턴스로 가야 한다.</p>
*/
@Component
public class ServerScopedPasswordKeyStore implements PasswordKeyStore {
private final PasswordCryptoProperties properties;
private volatile Holder current;
private volatile Holder previous;
public ServerScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
Holder holder = currentHolder();
int remaining = (int) Math.max(1, (holder.expiresAt - System.currentTimeMillis()) / 1000L);
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
Holder holder = current;
if (holder != null && holder.keyId.equals(keyId)) {
return holder.privateKey;
}
Holder old = previous;
if (old != null && old.keyId.equals(keyId)) {
return old.privateKey;
}
return null;
}
@Override
public void consume(String keyId, HttpServletRequest request) {
// 고정 키라 폐기하지 않는다.
}
private synchronized Holder currentHolder() {
long now = System.currentTimeMillis();
if (current == null || current.expiresAt < now) {
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
previous = current;
current = new Holder(
PasswordKeyPairs.newKeyId(),
keyPair.getPrivate(),
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
now + properties.getKeyTtlSeconds() * 1000L);
}
return current;
}
private static final class Holder {
private final String keyId;
private final PrivateKey privateKey;
private final String publicKeySpki;
private final long expiresAt;
private Holder(String keyId, PrivateKey privateKey, String publicKeySpki, long expiresAt) {
this.keyId = keyId;
this.privateKey = privateKey;
this.publicKeySpki = publicKeySpki;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,84 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.Serializable;
import java.security.KeyPair;
import java.security.PrivateKey;
/**
* 세션 단위로 키쌍을 보관하는 구현({@code key-scope: SESSION}).
*
* <p>개인키를 객체가 아니라 PKCS#8 바이트로 들고 있어 세션 복제(직렬화)에도 안전하다.
* 같은 keyId 가 세션 수명 동안 재사용되므로 재전송 방지는 IV 랜덤성과 애플리케이션의
* 인증 실패횟수 제한에 의존한다.</p>
*/
@Component
public class SessionScopedPasswordKeyStore implements PasswordKeyStore {
private static final String SESSION_ATTR = "DJB_PWD_CRYPTO_KEY";
private final PasswordCryptoProperties properties;
public SessionScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
HttpSession session = request.getSession(true);
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
long now = System.currentTimeMillis();
if (holder == null || holder.expiresAt < now) {
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
holder = new Holder(
PasswordKeyPairs.newKeyId(),
keyPair.getPrivate().getEncoded(),
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
now + properties.getKeyTtlSeconds() * 1000L);
session.setAttribute(SESSION_ATTR, holder);
}
int remaining = (int) Math.max(1, (holder.expiresAt - now) / 1000L);
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
if (holder == null || !holder.keyId.equals(keyId)) {
return null;
}
if (holder.expiresAt < System.currentTimeMillis()) {
session.removeAttribute(SESSION_ATTR);
return null;
}
return PasswordKeyPairs.toPrivateKey(holder.privateKeyPkcs8);
}
@Override
public void consume(String keyId, HttpServletRequest request) {
// 세션 수명 동안 재사용한다.
}
private static final class Holder implements Serializable {
private static final long serialVersionUID = 1L;
private final String keyId;
private final byte[] privateKeyPkcs8;
private final String publicKeySpki;
private final long expiresAt;
private Holder(String keyId, byte[] privateKeyPkcs8, String publicKeySpki, long expiresAt) {
this.keyId = keyId;
this.privateKeyPkcs8 = privateKeyPkcs8;
this.publicKeySpki = publicKeySpki;
this.expiresAt = expiresAt;
}
}
}
@@ -172,6 +172,50 @@ public class StringMaskingUtil {
return value; return value;
} }
/** 요청 파라미터 로깅 시 값을 통째로 가릴 키. 부분일치(소문자)로 본다. */
private static final String[] SENSITIVE_PARAM_KEYWORDS = {
"password", "passwd", "pwd", "secret", "credential", "token"};
/**
* 요청 파라미터 맵을 로그용 문자열로 만든다. 비밀번호·시크릿·토큰 계열 키는 값을 {@code [****]} 로 가린다.
*
* <p>비밀번호 전송암호화가 켜져 있어도 이 시점의 파라미터는 이미 복호화된 평문이므로,
* 마스킹 없이 로깅하면 암호화 조치가 무의미해진다.</p>
*
* <pre>id=[user@a.com], password=[****]</pre>
*/
public static String maskParameterMap(java.util.Map<String, String[]> parameterMap) {
if (parameterMap == null || parameterMap.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(entry.getKey()).append('=');
if (isSensitiveParamName(entry.getKey())) {
sb.append("[****]");
} else {
sb.append(Arrays.toString(entry.getValue()));
}
}
return sb.toString();
}
private static boolean isSensitiveParamName(String name) {
if (!isValidString(name)) {
return false;
}
String lower = name.toLowerCase();
for (String keyword : SENSITIVE_PARAM_KEYWORDS) {
if (lower.contains(keyword)) {
return true;
}
}
return false;
}
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치) // 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList( private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd", "client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.common.validator; package com.eactive.apim.portal.common.validator;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidator;
@@ -39,14 +40,8 @@ public class CellPhoneValidator implements ConstraintValidator<CellPhone, String
} }
// 기존의 prefix 체크 로직 유지 // 기존의 prefix 체크 로직 유지
return StringUtils.startsWith(cellPhone, "+821") || return Strings.CS.startsWithAny(cellPhone,
StringUtils.startsWith(cellPhone, "821") || "+821", "821", "010", "011", "016", "017", "018", "019");
StringUtils.startsWith(cellPhone, "010") ||
StringUtils.startsWith(cellPhone, "011") ||
StringUtils.startsWith(cellPhone, "016") ||
StringUtils.startsWith(cellPhone, "017") ||
StringUtils.startsWith(cellPhone, "018") ||
StringUtils.startsWith(cellPhone, "019");
} }
private boolean isValidPartLength(String prefix, String middle, String last) { private boolean isValidPartLength(String prefix, String middle, String last) {
@@ -1,7 +1,9 @@
package com.eactive.apim.portal.common.validator; package com.eactive.apim.portal.common.validator;
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.common.util.PhoneNumberUtil; import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import org.apache.commons.beanutils.PropertyUtils; import org.apache.commons.beanutils.PropertyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext; import javax.validation.ConstraintValidatorContext;
@@ -26,6 +28,23 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
// 공백 문자 정규식 // 공백 문자 정규식
private static final String BLANKPT = "(\\s)"; private static final String BLANKPT = "(\\s)";
/** 키보드 배열 인접 판정 기준 길이 (기존 연속 문자/숫자 규칙과 동일하게 3자) */
private static final int KEYBOARD_SEQUENCE_LENGTH = 3;
/** 키보드 배열 행. 대문자 기준으로 비교한다. */
private static final String[] KEYBOARD_ROWS = {
"QWERTYUIOP",
"ASDFGHJKL",
"ZXCVBNM",
"1234567890"
};
/**
* 키보드 연속 문자 차단 여부. Spring 이 관리하는 경로(LocalValidatorFactoryBean)에서만 주입되며,
* 순수 {@code new} 로 생성된 경우 null 이라 기본값(차단)으로 동작한다.
*/
@Autowired(required = false)
private PasswordPolicyProperties passwordPolicyProperties;
@Override @Override
public void initialize(PasswordRule constraintAnnotation) { public void initialize(PasswordRule constraintAnnotation) {
this.password = constraintAnnotation.password(); this.password = constraintAnnotation.password();
@@ -48,10 +67,21 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return false; return false;
} }
return isValid(passwordValue, loginIdValue, mobileNumberValue); return isValid(passwordValue, loginIdValue, mobileNumberValue, keyboardSequenceBlocked());
}
/** 주입이 안 된 경우(순수 new)에는 기본값인 차단으로 동작한다. */
private boolean keyboardSequenceBlocked() {
return passwordPolicyProperties == null
? PasswordPolicyProperties.DEFAULT_KEYBOARD_SEQUENCE_BLOCK
: passwordPolicyProperties.isKeyboardSequenceBlocked();
} }
public boolean isValid(String password, String loginId, String mobileNumber) { public boolean isValid(String password, String loginId, String mobileNumber) {
return isValid(password, loginId, mobileNumber, PasswordPolicyProperties.DEFAULT_KEYBOARD_SEQUENCE_BLOCK);
}
public boolean isValid(String password, String loginId, String mobileNumber, boolean blockKeyboardSequence) {
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식 // 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$"; String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
@@ -81,6 +111,11 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return false; return false;
} }
// 키보드 배열 연속 문자 체크 (PortalProperty 로 on/off)
if (blockKeyboardSequence && hasKeyboardSequence(password)) {
return false;
}
// 공백 체크 // 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw); matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
if (matcher.find()) { if (matcher.find()) {
@@ -118,6 +153,29 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return true; return true;
} }
/**
* 키보드 배열상 인접한 문자가 {@value #KEYBOARD_SEQUENCE_LENGTH} 자 이상 이어지는지 (정·역방향 모두).
*
* <p>같은 행(row) 안에서 좌우로 이어지는 경우만 본다. 예) {@code qwe}, {@code asd}, {@code trewq}.
* 클라이언트 {@code static/js/password-policy.js} 의 {@code hasKeyboardSequence} 와 동일 규칙이어야 한다.</p>
*/
public static boolean hasKeyboardSequence(String password) {
if (password == null || password.length() < KEYBOARD_SEQUENCE_LENGTH) {
return false;
}
String tmpPw = password.toUpperCase();
for (int i = 0; i <= tmpPw.length() - KEYBOARD_SEQUENCE_LENGTH; i++) {
String chunk = tmpPw.substring(i, i + KEYBOARD_SEQUENCE_LENGTH);
String reversed = new StringBuilder(chunk).reverse().toString();
for (String row : KEYBOARD_ROWS) {
if (row.contains(chunk) || row.contains(reversed)) {
return true;
}
}
}
return false;
}
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */ /** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
public static boolean containsLoginIdLocalPart(String password, String loginId) { public static boolean containsLoginIdLocalPart(String password, String loginId) {
if (password == null || loginId == null || loginId.isEmpty()) { if (password == null || loginId == null || loginId.isEmpty()) {
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.config;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.FileUpload;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.multipart.commons.CommonsMultipartResolver; import org.springframework.web.multipart.commons.CommonsMultipartResolver;
@@ -18,6 +20,18 @@ import org.springframework.web.multipart.commons.CommonsMultipartResolver;
@RequiredArgsConstructor @RequiredArgsConstructor
public class MultipartConfig { public class MultipartConfig {
/**
* 멀티파트 파트 1개의 헤더 구간 최대 바이트.
* <p>
* commons-fileupload 1.6.0(CVE-2025-48976 수정)부터 이 상한이 생겼고 기본값이 512 바이트다.
* 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 금방 커진다 — 실측상 기본값 512 로는
* 한글 약 137자 이상 파일명에서 업로드가 실패하고, 예외도 "Maximum upload size exceeded" 로 감싸져
* 원인 파악이 어렵다. OS 파일명 상한(255자)이 전부 한글이어도 통과하도록 2048 로 둔다.
* (1.5 이전에는 사실상 10240 이었으므로 이 값도 그보다 5배 엄격하다.)
* </p>
*/
private static final int PART_HEADER_SIZE_MAX = 2048;
private final PortalProperties portalProperties; private final PortalProperties portalProperties;
/** /**
@@ -31,7 +45,14 @@ public class MultipartConfig {
*/ */
@Bean(name = "filterMultipartResolver") @Bean(name = "filterMultipartResolver")
public CommonsMultipartResolver filterMultipartResolver() { public CommonsMultipartResolver filterMultipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver(); CommonsMultipartResolver resolver = new CommonsMultipartResolver() {
@Override
protected FileUpload newFileUpload(FileItemFactory fileItemFactory) {
FileUpload fileUpload = super.newFileUpload(fileItemFactory);
fileUpload.setPartHeaderSizeMax(PART_HEADER_SIZE_MAX);
return fileUpload;
}
};
long maxSizeBytes = portalProperties.getFile().getMaxSizeBytes(); long maxSizeBytes = portalProperties.getFile().getMaxSizeBytes();
resolver.setMaxUploadSize(maxSizeBytes); resolver.setMaxUploadSize(maxSizeBytes);
@@ -39,8 +60,8 @@ public class MultipartConfig {
resolver.setMaxInMemorySize((int) Math.min(maxSizeBytes, Integer.MAX_VALUE)); resolver.setMaxInMemorySize((int) Math.min(maxSizeBytes, Integer.MAX_VALUE));
resolver.setDefaultEncoding("UTF-8"); resolver.setDefaultEncoding("UTF-8");
log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({})", log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({}), partHeaderSizeMax: {} bytes",
maxSizeBytes, portalProperties.getFile().getMaxSize()); maxSizeBytes, portalProperties.getFile().getMaxSize(), PART_HEADER_SIZE_MAX);
return resolver; return resolver;
} }
@@ -4,16 +4,13 @@ import com.eactive.apim.portal.apps.login.constants.LoginConstants;
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason; import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService; import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.exception.UserNotFoundException; import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.security.AccountLockService;
import com.eactive.apim.portal.common.security.LoginLockPolicy; import com.eactive.apim.portal.common.security.LoginLockPolicy;
import com.eactive.apim.portal.common.util.HttpRequestUtil; import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil; import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil; import com.eactive.apim.portal.common.util.StringRepeatUtil;
import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import org.apache.groovy.util.Maps;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
@@ -47,18 +44,18 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
private final PortalUserRepository portalUserRepository; private final PortalUserRepository portalUserRepository;
private final PortalUserLogService userLogService; private final PortalUserLogService userLogService;
private final MessageHandlerService messageHandlerService;
private final LoginLockPolicy loginLockPolicy; private final LoginLockPolicy loginLockPolicy;
private final AccountLockService accountLockService;
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository, public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
PortalUserLogService userLogService, PortalUserLogService userLogService,
MessageHandlerService messageHandlerService, LoginLockPolicy loginLockPolicy,
LoginLockPolicy loginLockPolicy) { AccountLockService accountLockService) {
this.portalUserRepository = portalUserRepository; this.portalUserRepository = portalUserRepository;
this.userLogService = userLogService; this.userLogService = userLogService;
this.messageHandlerService = messageHandlerService;
this.loginLockPolicy = loginLockPolicy; this.loginLockPolicy = loginLockPolicy;
this.accountLockService = accountLockService;
} }
@Override @Override
@@ -79,16 +76,10 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
int lockCount = loginLockPolicy.lockCount(); int lockCount = loginLockPolicy.lockCount();
user.setLoginFailureCount(user.getLoginFailureCount() + 1); user.setLoginFailureCount(user.getLoginFailureCount() + 1);
if (user.getLoginFailureCount() >= lockCount) {
user.setAccountLockYn("Y");
// 계정 잠금 알림
messageHandlerService.publishEvent(
MessageCode.USER_ACCOUNT_LOCKED,
MessageRecipient.of(user),
Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
}
portalUserRepository.save(user); portalUserRepository.save(user);
if (user.getLoginFailureCount() >= lockCount) {
accountLockService.lock(user, lockCount + "회 이상 로그인 실패로 인한 계정 잠금");
}
@@ -2,11 +2,13 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter; import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter; import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -16,6 +18,7 @@ import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl; import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfException; import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -62,6 +65,20 @@ public class PortalConfigSecurity {
return registration; return registration;
} }
/**
* 비밀번호 전송암호화 복호화 필터. Lucy XSS 필터(order = MIN_VALUE + 1)보다 <b>먼저</b> 실행되어야 한다.
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야 특수문자가 든 비밀번호의 해시 비교 결과가
* 지금과 동일하게 유지된다. 순서를 뒤집으면 기존 계정 로그인이 깨진다.
*/
@Bean
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
PasswordDecryptFilter filter) {
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
registrationBean.setOrder(Integer.MIN_VALUE);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
@Bean @Bean
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() { public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>(); FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
@@ -92,6 +109,23 @@ public class PortalConfigSecurity {
http http
.authenticationManager(portalAuthenticationManager) .authenticationManager(portalAuthenticationManager)
// CVE-2026-22732 우회책. Spring Security 는 기본적으로 보안 헤더를 "응답 커밋 시점"에
// 지연 기록하는데, 응답 래퍼가 Content-Length 를 setHeader/setIntHeader/addIntHeader 로
// 지정하는 경로를 추적하지 못해 그 경우 헤더가 통째로 누락된다
// (X-Content-Type-Options, X-Frame-Options, Cache-Control, Pragma, Expires, X-XSS-Protection).
// 수정본은 5.7.22/5.8.24(Enterprise 전용)뿐이라 OSS 로는 올릴 수 없어 우회책을 적용한다.
// 요청 시작 시점에 헤더를 기록하게 만든다. 앱이 나중에 같은 헤더를 지정하면 앱 값이 남는다
// (실측: FileDownloadController#viewImage 의 Cache-Control: public, max-age=86400 유지됨).
// 현재 앱은 response.setContentLength(int) 만 쓰므로 노출 경로는 없지만,
// 새 코드가 위 메서드를 쓰더라도 헤더가 빠지지 않도록 두는 안전망이다.
// Spring Security 를 수정본(6.5.9+/7.0.4+ 또는 Enterprise 5.7.22+)으로 올리면 제거 가능.
.headers(headers -> headers.addObjectPostProcessor(new ObjectPostProcessor<HeaderWriterFilter>() {
@Override
public <O extends HeaderWriterFilter> O postProcess(O filter) {
filter.setShouldWriteHeadersEagerly(true);
return filter;
}
}))
.formLogin(form -> form .formLogin(form -> form
.loginPage("/login") .loginPage("/login")
.usernameParameter("id") .usernameParameter("id")
@@ -57,6 +57,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Value("${app.resource-caching.enabled:false}") @Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled; private boolean resourceCachingEnabled;
// 정적자원 서빙 루트(application.yml: app.web-resources.static-base).
// 기본은 classpath(빌드 산출물), local_rinjaemac 프로파일은 file:${user.dir}/src/main/resources/static/
// 로 오버라이드해 소스 편집이 재빌드 없이 즉시 반영되도록 한다.
@Value("${app.web-resources.static-base:classpath:/static/}")
private String staticBase;
public PortalConfigWebDispatcherServlet(Environment environment, public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService, com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties, com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
@@ -151,15 +157,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override @Override
public void addResourceHandlers(ResourceHandlerRegistry registry) { public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/"); addStaticResourceHandler(registry, "/css/**", "/css/", staticBase + "css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/"); addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", staticBase + "webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/"); addStaticResourceHandler(registry, "/font/**", "/font/", staticBase + "font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/"); addStaticResourceHandler(registry, "/html/**", "/html/", staticBase + "html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/"); addStaticResourceHandler(registry, "/images/**", "/images/", staticBase + "images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/"); addStaticResourceHandler(registry, "/img/**", "/img/", staticBase + "img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/"); addStaticResourceHandler(registry, "/js/**", "/js/", staticBase + "js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/"); addStaticResourceHandler(registry, "/plugins/**", "/plugins/", staticBase + "plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico"); addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", staticBase + "favicon.ico");
} }
/** /**
@@ -27,13 +27,23 @@ import java.util.Optional;
* *
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면 * <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p> * NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
*
* <p>API 그룹으로 특정 법인에게만 공개된 API 의 이슈는, 그 API 를 조회할 수 없는 사용자에게는
* 이슈 자체(제목·요약·타임라인 포함)를 노출하지 않는다. 영향 API 가 하나도 없는 이슈(전사 공지성)는
* 그대로 노출한다. {@code visibleApiIds} 는 {@code ApiStatusCatalogService#getVisibleApiIdsForQuery()}
* 로 구한, 현재 사용자에게 공개된 API ID 집합이다.</p>
*/ */
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> { public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
String VISIBLE = " i.draftYn = 'N'" String VISIBLE = " i.draftYn = 'N'"
+ " AND (i.noticeId IS NULL" + " AND (i.noticeId IS NULL"
+ " OR EXISTS (SELECT 1 FROM PortalNotice n" + " OR EXISTS (SELECT 1 FROM PortalNotice n"
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) "; + " WHERE n.id = i.noticeId AND n.useYn = 'Y'))"
+ " AND (NOT EXISTS (SELECT 1 FROM DjbApistatusIncidentApi ia"
+ " WHERE ia.incidentId = i.incidentId)"
+ " OR EXISTS (SELECT 1 FROM DjbApistatusIncidentApi ia2"
+ " WHERE ia2.incidentId = i.incidentId"
+ " AND ia2.apiId IN :visibleApiIds)) ";
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */ /** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT"; String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
@@ -55,7 +65,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND i.state NOT IN :closedStates" + " AND i.state NOT IN :closedStates"
+ " ORDER BY i.startedAt DESC") + " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds, List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
@Param("closedStates") Collection<IncidentState> closedStates); @Param("closedStates") Collection<IncidentState> closedStates,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/** /**
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검. * 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
@@ -66,7 +77,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND (i.endAt IS NULL OR i.endAt >= :now)" + " AND (i.endAt IS NULL OR i.endAt >= :now)"
+ " ORDER BY i.startedAt ASC") + " ORDER BY i.startedAt ASC")
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind, List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
@Param("now") LocalDateTime now); @Param("now") LocalDateTime now,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/** /**
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과. * 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
@@ -80,6 +92,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND " + CLOSED_CONDITION) + " AND " + CLOSED_CONDITION)
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates, Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
@Param("now") LocalDateTime now, @Param("now") LocalDateTime now,
@Param("visibleApiIds") Collection<String> visibleApiIds,
Pageable pageable); Pageable pageable);
/** /**
@@ -92,7 +105,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND (i.endAt IS NULL OR i.endAt >= :from)" + " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC") + " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from, List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to); @Param("to") LocalDateTime to,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/** /**
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터). * 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
@@ -106,7 +120,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " ORDER BY i.startedAt DESC") + " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from, List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to, @Param("to") LocalDateTime to,
@Param("apiId") String apiId); @Param("apiId") String apiId,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/** /**
* 공개 상세 (P7) * 공개 상세 (P7)
@@ -114,5 +129,6 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
@Query("SELECT i FROM DjbApistatusIncident i" @Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE + " WHERE " + VISIBLE
+ " AND i.incidentId = :incidentId") + " AND i.incidentId = :incidentId")
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId); Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId,
@Param("visibleApiIds") Collection<String> visibleApiIds);
} }
@@ -152,7 +152,8 @@ public class ApiCurrentStatusService {
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */ /** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) { private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
List<DjbApistatusIncident> openIncidents = incidentQueryRepository List<DjbApistatusIncident> openIncidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES); .findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES,
catalogService.getVisibleApiIdsForQuery());
if (openIncidents.isEmpty()) { if (openIncidents.isEmpty()) {
return Collections.emptyMap(); return Collections.emptyMap();
} }
@@ -178,7 +179,8 @@ public class ApiCurrentStatusService {
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */ /** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) { private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
List<DjbApistatusIncident> maintenances = incidentQueryRepository List<DjbApistatusIncident> maintenances = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream() .findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now,
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now)) .filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
.collect(Collectors.toList()); .collect(Collectors.toList());
if (maintenances.isEmpty()) { if (maintenances.isEmpty()) {
@@ -208,7 +210,8 @@ public class ApiCurrentStatusService {
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) { private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay(); LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
List<DjbApistatusIncident> incidents = incidentQueryRepository List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream() .findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading()) .filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList()); .collect(Collectors.toList());
if (incidents.isEmpty()) { if (incidents.isEmpty()) {
@@ -16,6 +16,7 @@ import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext; import javax.persistence.PersistenceContext;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -99,6 +100,19 @@ public class ApiStatusCatalogService {
return names; return names;
} }
/** IN 절 바인딩용 API ID 없을 리 없는 더미값. 실제 API ID 는 절대 이 값이 될 수 없다 */
private static final List<String> NO_VISIBLE_API = Collections.singletonList("__NO_VISIBLE_API__");
/**
* {@link ApiStatusIncidentQueryRepository} 의 {@code :visibleApiIds} 바인딩용.
* Oracle 은 빈 컬렉션으로 {@code IN ()} 을 만들면 구문 오류가 나므로, 조회 가능한 API 가
* 하나도 없을 때는 절대 매치되지 않는 더미값으로 대체한다.
*/
public Collection<String> getVisibleApiIdsForQuery() {
Map<String, String> names = getVisibleApiNames();
return names.isEmpty() ? NO_VISIBLE_API : names.keySet();
}
/** /**
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각. * API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
* *
@@ -130,9 +130,10 @@ public class ApiStatusIssueHistoryService {
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to, private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
String apiId, String kind) { String apiId, String kind) {
java.util.Collection<String> visibleApiIds = catalogService.getVisibleApiIdsForQuery();
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId) List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
? incidentQueryRepository.findVisibleOverlapping(from, to) ? incidentQueryRepository.findVisibleOverlapping(from, to, visibleApiIds)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId); : incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId, visibleApiIds);
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind); java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (filter == null) { if (filter == null) {
@@ -25,19 +25,22 @@ public class ApiStatusQueryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository; private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler; private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
/** P3 - 진행 중 장애·지연 */ /** P3 - 진행 중 장애·지연 */
public List<ActiveIncidentDTO> getActiveIncidents() { public List<ActiveIncidentDTO> getActiveIncidents() {
LocalDateTime now = ApiStatusSupport.now(); LocalDateTime now = ApiStatusSupport.now();
List<DjbApistatusIncident> incidents = incidentQueryRepository List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES); .findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES,
catalogService.getVisibleApiIdsForQuery());
return assembler.toActiveIncidents(incidents, now); return assembler.toActiveIncidents(incidents, now);
} }
/** P5 - 예정/진행 중 점검 */ /** P5 - 예정/진행 중 점검 */
public List<MaintenanceCardDTO> getOngoingMaintenance() { public List<MaintenanceCardDTO> getOngoingMaintenance() {
List<DjbApistatusIncident> incidents = incidentQueryRepository List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now()); .findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now(),
catalogService.getVisibleApiIdsForQuery());
return assembler.toMaintenanceCards(incidents); return assembler.toMaintenanceCards(incidents);
} }
@@ -46,14 +49,14 @@ public class ApiStatusQueryService {
int limit = size <= 0 ? 5 : Math.min(size, 50); int limit = size <= 0 ? 5 : Math.min(size, 50);
List<DjbApistatusIncident> incidents = incidentQueryRepository List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(), .findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
PageRequest.of(0, limit)) catalogService.getVisibleApiIdsForQuery(), PageRequest.of(0, limit))
.getContent(); .getContent();
return assembler.toPastIssueCards(incidents); return assembler.toPastIssueCards(incidents);
} }
/** P7 - 이슈 공개 상세 */ /** P7 - 이슈 공개 상세 */
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) { public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
return incidentQueryRepository.findVisibleById(incidentId) return incidentQueryRepository.findVisibleById(incidentId, catalogService.getVisibleApiIdsForQuery())
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0)); .map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
} }
} }
@@ -34,6 +34,7 @@ public class ApiStatusUptimeService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository; private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository; private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiStatusCatalogService catalogService;
/** P2 - 90일 가동률 */ /** P2 - 90일 가동률 */
public List<DailyStatDTO> getDailyStats(int days) { public List<DailyStatDTO> getDailyStats(int days) {
@@ -43,7 +44,8 @@ public class ApiStatusUptimeService {
LocalDate from = today.minusDays(windowDays - 1L); LocalDate from = today.minusDays(windowDays - 1L);
List<DjbApistatusIncident> incidents = incidentQueryRepository List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay()); .findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery());
List<DailyStatDTO> result = new ArrayList<>(); List<DailyStatDTO> result = new ArrayList<>();
for (int offset = 0; offset < windowDays; offset++) { for (int offset = 0; offset < windowDays; offset++) {
@@ -66,7 +68,8 @@ public class ApiStatusUptimeService {
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외. // 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
List<DjbApistatusIncident> incidents = incidentQueryRepository List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream() .findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading()) .filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -1,5 +1,8 @@
package com.eactive.apim.portal.djb.community.qna.comment.controller; package com.eactive.apim.portal.djb.community.qna.comment.controller;
import com.eactive.apim.portal.common.exception.TooManyWriteRequestsException;
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.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest; import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
@@ -32,6 +35,7 @@ import java.util.Map;
public class InquiryCommentController { public class InquiryCommentController {
private final InquiryCommentFacade inquiryCommentFacade; private final InquiryCommentFacade inquiryCommentFacade;
private final WriteRateLimitService writeRateLimitService;
@GetMapping("/{inquiryId}/comments") @GetMapping("/{inquiryId}/comments")
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) { public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
@@ -42,6 +46,7 @@ public class InquiryCommentController {
@PostMapping("/{inquiryId}/comments") @PostMapping("/{inquiryId}/comments")
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId, public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
@Valid @RequestBody InquiryCommentCreateRequest request) { @Valid @RequestBody InquiryCommentCreateRequest request) {
writeRateLimitService.assertNotExceeded(WriteTarget.INQUIRY_COMMENT);
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
InquiryCommentDTO created = inquiryCommentFacade.createUserComment( InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
inquiryId, request.getContent(), request.getVisibility(), current); inquiryId, request.getContent(), request.getVisibility(), current);
@@ -65,6 +70,12 @@ public class InquiryCommentController {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage())); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage()));
} }
@ExceptionHandler(TooManyWriteRequestsException.class)
public ResponseEntity<Map<String, String>> handleTooManyRequests(TooManyWriteRequestsException ex) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(errorBody("TOO_MANY_REQUESTS", ex.getMessage()));
}
private Map<String, String> errorBody(String code, String message) { private Map<String, String> errorBody(String code, String message) {
Map<String, String> body = new HashMap<>(); Map<String, String> body = new HashMap<>();
body.put("code", code); body.put("code", code);
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.djb.community.qna.comment.repository; package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.qna.entity.InquiryComment; import com.eactive.apim.portal.qna.entity.InquiryComment;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -21,4 +22,15 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
@Transactional @Transactional
long deleteByInquiry_Inquirer_Id(String inquirerId); long deleteByInquiry_Inquirer_Id(String inquirerId);
/** 4010 테스트 cleanup 전용 — 삭제 대상 문의글 id 목록에 딸린 댓글을 함께 지운다. */
@Transactional
long deleteByInquiry_IdIn(Collection<String> inquiryIds);
/**
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 댓글을 최신순으로 조회한다(Pageable 로 건수 제한).
* createdBy 는 결정적 암호화라 평문 사용자 id 로 등가 조회가 가능하다.
* 삭제(delYn='Y')된 댓글도 포함한다 — 작성 후 지우는 방식의 우회를 막기 위함.
*/
List<InquiryComment> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
} }
@@ -0,0 +1,73 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 알림 발송 테스트 이력 1건. HttpSession(속성 {@code NOTI_TEST_HISTORY})에 리스트로 보관한다.
*
* <p>DB 테이블을 신설하지 않는 요구사항에 따라, 상태는 저장하지 않고 매 폴링마다
* {@code requestIds}로 PTL_MESSAGE_REQUEST를 다시 조회해 {@link #refreshStatus} 로 갱신한다.</p>
*/
@Getter
public class NotiTestHistoryEntry implements Serializable {
private static final long serialVersionUID = 1L;
private final String entryId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private final LocalDateTime requestedAt;
private final NotiTestTarget.Category category;
private final String targetLabel;
private final String messageCode;
private final NotiTestTarget.Strategy strategy;
private final String recipient;
private final List<String> messages;
private final List<String> requestIds;
private final Map<String, String> statuses = new LinkedHashMap<>();
private final String warning;
private final String errorMessage;
private NotiTestHistoryEntry(NotiTestTarget target, String recipient, NotiTestResult result) {
this.entryId = UUID.randomUUID().toString();
this.requestedAt = LocalDateTime.now();
this.category = target.getCategory();
this.targetLabel = target.getLabel();
this.messageCode = target.getMessageCode().name();
this.strategy = target.getStrategy();
this.recipient = recipient;
this.messages = result.getCreated().stream()
.map(MessageRequest::getMessage)
.distinct()
.collect(Collectors.toList());
this.requestIds = result.getCreated().stream().map(MessageRequest::getId).collect(Collectors.toList());
this.warning = result.getWarning();
this.errorMessage = result.getErrorMessage();
for (MessageRequest request : result.getCreated()) {
this.statuses.put(request.getId(), request.getRequestStatus());
}
}
public static NotiTestHistoryEntry of(NotiTestTarget target, String recipient, NotiTestResult result) {
return new NotiTestHistoryEntry(target, recipient, result);
}
/** 폴링 시 최신 상태로 덮어쓴다. 세션에 다시 저장하는 쪽은 호출부 책임이다. */
public void refreshStatus(Map<String, MessageRequest> byId) {
for (String id : requestIds) {
MessageRequest request = byId.get(id);
if (request != null) {
statuses.put(id, request.getRequestStatus());
}
}
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 알림 발송 테스트 페이지 관련 {@code PTL_PROPERTY} 접근 래퍼.
*
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다({@link com.eactive.apim.portal.djb.swing.SwingNotifyProperties} 참고).
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를 생성하므로
* 별도 초기 데이터 없이도 동작하며, 운영자는 PTL_PROPERTY 값만 바꿔 재배포 없이 on/off·계정 추가가 가능하다.</p>
*/
@Component
@RequiredArgsConstructor
public class NotiTestProperties {
public static final String GROUP = "Portal";
public static final String KEY_ENABLED = "notification-test.enabled";
public static final String KEY_ALLOWED_EMAILS = "notification-test.allowed-emails";
private static final String LIST_DELIMITERS = "[,;\\r\\n]";
private final PortalPropertyService portalPropertyService;
public boolean isEnabled() {
String value = resolve(KEY_ENABLED, "false", "알림 발송 테스트 페이지 활성화 여부(true/false)");
return "true".equalsIgnoreCase(value == null ? null : value.trim());
}
public boolean isAllowed(String email) {
if (email == null || email.trim().isEmpty()) {
return false;
}
String allowList = resolve(KEY_ALLOWED_EMAILS, "",
"알림 발송 테스트 페이지 접근 허용 이메일 목록 (콤마/세미콜론/줄바꿈 구분)");
if (allowList == null || allowList.trim().isEmpty()) {
return false;
}
String target = email.trim();
for (String token : allowList.split(LIST_DELIMITERS)) {
String candidate = token.trim();
if (!candidate.isEmpty() && candidate.equalsIgnoreCase(target)) {
return true;
}
}
return false;
}
private String resolve(String key, String defaultValue, String description) {
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
}
}
@@ -0,0 +1,35 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.template.entity.MessageRequest;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
/**
* {@link NotiTestService#send} 한 번 호출의 결과. 화면/세션에는 이 값을 그대로 담지 않고
* {@link NotiTestHistoryEntry}로 변환해서 보관한다(엔티티를 세션에 직렬화하지 않기 위함).
*/
@Getter
public class NotiTestResult {
private final boolean success;
private final List<MessageRequest> created;
private final String warning;
private final String errorMessage;
private NotiTestResult(boolean success, List<MessageRequest> created, String warning, String errorMessage) {
this.success = success;
this.created = created;
this.warning = warning;
this.errorMessage = errorMessage;
}
public static NotiTestResult of(List<MessageRequest> created, String warning) {
return new NotiTestResult(true, created == null ? new ArrayList<>() : created, warning, null);
}
public static NotiTestResult error(String errorMessage) {
return new NotiTestResult(false, new ArrayList<>(), null, errorMessage);
}
}
@@ -0,0 +1,121 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.djb.swing.SwingMessageWriter;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageTemplateRepository;
import com.eactive.apim.portal.template.service.MessageEventHandler;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.template.service.MessageSendEvent;
import com.eactive.apim.portal.template.service.MessageSendService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 알림 발송 테스트 페이지의 발송 로직. {@link NotiTestTarget.Strategy} 에 따라 세 갈래로 분기한다.
*
* <p>HANDLER 전략은 {@code MessageHandlerService.publishEvent}(이벤트버스, fire-and-forget)를 쓰지 않는다 —
* 반환값을 받을 방법이 없기 때문이다. 대신 핸들러의 {@code createEvent}만 재사용해 파라미터 매핑 로직은
* 그대로 살리고, {@code MessageSendService.sendMessage}를 직접 호출해 생성된 {@link MessageRequest} 목록을
* 돌려받는다. {@code MessageSendEvent} 구독자는 {@code MessageHandlerService.handleMessageSendEvent} 한
* 곳뿐이라 이 우회는 안전하다.</p>
*/
@Service
@RequiredArgsConstructor
public class NotiTestService {
private final MessageHandlerService messageHandlerService;
private final MessageSendService messageSendService;
private final SwingMessageWriter swingMessageWriter;
private final MessageTemplateRepository messageTemplateRepository;
public NotiTestResult send(String messageCodeName, Map<String, String> formParams) {
Optional<NotiTestTarget> targetOpt = NotiTestTarget.byMessageCode(messageCodeName);
if (!targetOpt.isPresent()) {
return NotiTestResult.error("알 수 없는 테스트 대상입니다: " + messageCodeName);
}
NotiTestTarget target = targetOpt.get();
Map<String, String> domainParams = extractDomainParams(target, formParams);
switch (target.getStrategy()) {
case HANDLER:
return sendViaHandler(target, buildRecipient(formParams), domainParams);
case DIRECT:
return sendDirect(target, buildRecipient(formParams), domainParams);
case BROADCAST:
return sendBroadcast(target, domainParams);
default:
return NotiTestResult.error("알 수 없는 발송 전략입니다: " + target.getStrategy());
}
}
private NotiTestResult sendViaHandler(NotiTestTarget target, MessageRecipient recipient,
Map<String, String> domainParams) {
MessageEventHandler handler = messageHandlerService.getHandlers().get(target.getMessageCode().name());
if (handler == null) {
// 카탈로그가 HANDLER 로 분류했는데 빈이 없는 방어적 상황(운영 배포 누락 등)
return NotiTestResult.error("핸들러 빈이 등록되어 있지 않습니다: " + target.getMessageCode());
}
Map<String, Object> rawParams = new HashMap<>(domainParams);
MessageSendEvent event = handler.createEvent(this, recipient, rawParams);
List<MessageRequest> created =
messageSendService.sendMessage(event.getMessageCode(), event.getRecipient(), event.getParams());
return NotiTestResult.of(created, additionalRecipientWarning(target));
}
private NotiTestResult sendDirect(NotiTestTarget target, MessageRecipient recipient,
Map<String, String> domainParams) {
List<MessageRequest> created =
messageSendService.sendMessage(target.getMessageCode(), recipient, new HashMap<>(domainParams));
return NotiTestResult.of(created, additionalRecipientWarning(target));
}
private NotiTestResult sendBroadcast(NotiTestTarget target, Map<String, String> domainParams) {
Map<String, Object> params = new HashMap<>(domainParams);
List<MessageRequest> created = swingMessageWriter.write(target.getMessageCode(), params);
return NotiTestResult.of(created, null);
}
/** 실서비스 템플릿에 추가 수신자(PTL_MESSAGE_RECIPIENT)가 등록돼 있으면 테스트 발송에도 함께 나간다는 경고. */
private String additionalRecipientWarning(NotiTestTarget target) {
return messageTemplateRepository.findById(target.getMessageCode().name())
.map(t -> t.getAdditionalRecipients())
.filter(list -> list != null && !list.isEmpty())
.map(list -> "이 항목은 실서비스 템플릿을 사용합니다. 등록된 추가 수신자 "
+ list.size() + "명에게도 함께 발송됩니다.")
.orElse(null);
}
/** {@code param_<key>} 폼 필드를 읽되, 비어있으면 카탈로그의 샘플값으로 채운다. */
private Map<String, String> extractDomainParams(NotiTestTarget target, Map<String, String> formParams) {
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, String> sample : target.getSampleParams().entrySet()) {
String value = formParams.get("param_" + sample.getKey());
result.put(sample.getKey(), StringUtils.hasText(value) ? value.trim() : sample.getValue());
}
return result;
}
private MessageRecipient buildRecipient(Map<String, String> formParams) {
String email = trimToNull(formParams.get("recipientEmail"));
return MessageRecipient.builder()
.username(trimToNull(formParams.get("recipientUsername")))
.userId(email)
.email(email)
.phone(trimToNull(formParams.get("recipientPhone")))
.messengerId(trimToNull(formParams.get("recipientMessengerId")))
.build();
}
private static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}
@@ -0,0 +1,116 @@
package com.eactive.apim.portal.djb.notitest.catalog;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.Getter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* 알림 발송 테스트 페이지가 다루는 16종 테스트 대상 카탈로그.
*
* <p>발송 경로가 코드 위치에 따라 세 갈래로 갈린다({@link Strategy} 참고). 항목을 추가/변경할 때는
* 이 enum 하나만 고치면 된다 — 컨트롤러/서비스/화면은 이 카탈로그를 그대로 순회한다.</p>
*/
@Getter
public enum NotiTestTarget {
// ---- 전략 HANDLER: elink-portal-common 의 MessageEventHandler 재사용 ----
EMAIL_VERIFICATION(Category.EMAIL, "이메일 인증코드", MessageCode.USER_VERIFICATION_EMAIL,
Strategy.HANDLER, params("authNumber", "123456")),
SMS_VERIFICATION(Category.SMS, "SMS 인증코드", MessageCode.USER_VERIFICATION_MOBILEPHONE,
Strategy.HANDLER, params("authNumber", "123456")),
SMS_ACCOUNT_LOCKED(Category.SMS, "계정 잠김", MessageCode.USER_ACCOUNT_LOCKED,
Strategy.HANDLER, params("reason", "로그인 5회 실패")),
SMS_PASSWORD_CHANGED(Category.SMS, "비밀번호 변경", MessageCode.USER_PASSWORD_CHANGED,
Strategy.HANDLER, params()),
SMS_INVITATION(Category.SMS, "법인 내 초대", MessageCode.USER_INVITATION,
Strategy.HANDLER, params(
"corpName", "(주)테스트법인",
"managerName", "홍길동",
"url", "https://example.com/invite",
"authNumber", "123456")),
SMS_INVITATION_CANCELED(Category.SMS, "법인 내 초대 취소", MessageCode.USER_INVITATION_CANCELED,
Strategy.HANDLER, params(
"corpName", "(주)테스트법인",
"managerName", "홍길동")),
// ---- 전략 DIRECT: eapim-admin 전용 핸들러(클래스패스 미공유) — MessageSendService 직접 호출 ----
SMS_ORG_APPROVED(Category.SMS, "서비스 가입 승인", MessageCode.MANAGER_WITH_ORG_REGISTER_APPROVED,
Strategy.DIRECT, params("ORG_NAME", "(주)테스트법인")),
SMS_ORG_REJECTED(Category.SMS, "서비스 가입 거절", MessageCode.MANAGER_WITH_ORG_REGISTER_REJECTED,
Strategy.DIRECT, params("ORG_NAME", "(주)테스트법인", "REASON", "서류 미비")),
SMS_APP_APPROVED(Category.SMS, "클라이언트 승인", MessageCode.APP_APPROVE,
Strategy.DIRECT, params("APP_NAME", "테스트앱")),
SMS_APP_REJECTED(Category.SMS, "클라이언트 거절", MessageCode.APP_REJECTED,
Strategy.DIRECT, params("APP_NAME", "테스트앱", "REASON", "정책 위반")),
SMS_ADMIN_LOGIN(Category.SMS, "관리자포탈 로그인", MessageCode.ADMIN_VERIFICATION_MOBILEPHONE,
Strategy.DIRECT, params("authNumber", "123456", "userName", "관리자")),
MSG_API_STATUS(Category.MESSENGER, "API 상태 감시", MessageCode.API_STATUS_CHANGED,
Strategy.DIRECT, params("message", "[테스트] API 상태 변화 알림")),
MSG_INFLOW_TOKEN(Category.MESSENGER, "이상 징후 감시", MessageCode.INFLOW_TOKEN_FAILED,
Strategy.DIRECT, params("message", "[테스트] 유량제어 토큰 획득 실패")),
// ---- 전략 BROADCAST: SwingMessageWriter 직접 호출(PTL_PROPERTY 역할의 내부직원 전원에게 발송) ----
MSG_QNA_CREATED(Category.MESSENGER, "QnA 질문등록", MessageCode.INQUIRY_CREATED,
Strategy.BROADCAST, params(
"inquiryId", "TEST-0001",
"inquirySubject", "[테스트] 문의 제목",
"writerName", "홍길동")),
MSG_QNA_COMMENT(Category.MESSENGER, "QnA 댓글 등록", MessageCode.INQUIRY_COMMENT_CREATED,
Strategy.BROADCAST, params(
"inquiryId", "TEST-0001",
"inquirySubject", "[테스트] 문의 제목",
"commentContent", "테스트 댓글입니다",
"writerName", "홍길동")),
MSG_PARTNERSHIP(Category.MESSENGER, "개선요청 새글 등록", MessageCode.PARTNERSHIP_CREATED,
Strategy.BROADCAST, params(
"partnershipId", "TEST-0001",
"bizSubject", "[테스트] 개선요청 제목",
"writerName", "홍길동")),
;
@Getter
public enum Category {
EMAIL("이메일"), SMS("SMS"), MESSENGER("메신저");
private final String label;
Category(String label) {
this.label = label;
}
}
public enum Strategy { HANDLER, DIRECT, BROADCAST }
private final Category category;
private final String label;
private final MessageCode messageCode;
private final Strategy strategy;
private final Map<String, String> sampleParams;
NotiTestTarget(Category category, String label, MessageCode messageCode, Strategy strategy,
Map<String, String> sampleParams) {
this.category = category;
this.label = label;
this.messageCode = messageCode;
this.strategy = strategy;
this.sampleParams = sampleParams;
}
public static Optional<NotiTestTarget> byMessageCode(String messageCodeName) {
return Arrays.stream(values())
.filter(t -> t.messageCode.name().equalsIgnoreCase(messageCodeName))
.findFirst();
}
private static Map<String, String> params(String... kv) {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) {
map.put(kv[i], kv[i + 1]);
}
return map;
}
}
@@ -0,0 +1,194 @@
package com.eactive.apim.portal.djb.notitest.controller;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.notitest.NotiTestHistoryEntry;
import com.eactive.apim.portal.djb.notitest.NotiTestProperties;
import com.eactive.apim.portal.djb.notitest.NotiTestResult;
import com.eactive.apim.portal.djb.notitest.NotiTestService;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
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 org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 알림 발송 테스트 페이지.
*
* <p>PTL_PROPERTY({@link NotiTestProperties})로 기능 on/off 와 접근 허용 이메일 목록을 관리한다.
* 운영에서도 상시 켜둘 수 있도록 GNB 에는 노출하지 않고 직접 URL 로만 접근한다(page.yml/portal.pages
* 미등록). 발송 이력은 별도 테이블 없이 HttpSession 에 보관하고, 상태는 기존 PTL_MESSAGE_REQUEST 를
* 3초 폴링으로 재조회해 갱신한다.</p>
*/
@Slf4j
@Controller
@RequestMapping("//notitest")
@RequiredArgsConstructor
public class NotiTestController {
private static final String SESSION_HISTORY_KEY = "NOTI_TEST_HISTORY";
private static final int MAX_HISTORY = 50;
private final NotiTestProperties notiTestProperties;
private final NotiTestService notiTestService;
private final MessageRequestRepository messageRequestRepository;
/** 폼 + 이력 렌더 */
@GetMapping
public ModelAndView index(HttpSession session, HttpServletResponse response) {
if (!notiTestProperties.isEnabled()) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return null;
}
if (!isAllowedForCurrentUser()) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return null;
}
PortalAuthenticatedUser me = SecurityUtil.getPortalAuthenticatedUser();
ModelAndView mav = new ModelAndView("djb/notitest/index");
mav.addObject("targetsByCategory", groupByCategory());
mav.addObject("history", getHistory(session));
mav.addObject("defaultUsername", me.getUserName());
mav.addObject("defaultEmail", me.getEmailAddr());
mav.addObject("defaultPhone", me.getMobileNumber());
return mav;
}
/** 발송(AJAX) */
@PostMapping("/send")
@ResponseBody
public ResponseEntity<?> send(@RequestParam Map<String, String> allParams, HttpSession session) {
ResponseEntity<?> denied = guardResponse();
if (denied != null) {
return denied;
}
String messageCode = allParams.get("messageCode");
if (!StringUtils.hasText(messageCode)) {
return ResponseEntity.badRequest().body(Collections.singletonMap("error", "messageCode is required"));
}
NotiTestTarget target = NotiTestTarget.byMessageCode(messageCode).orElse(null);
if (target == null) {
return ResponseEntity.badRequest()
.body(Collections.singletonMap("error", "알 수 없는 테스트 대상입니다: " + messageCode));
}
NotiTestResult result = notiTestService.send(messageCode, allParams);
NotiTestHistoryEntry entry = NotiTestHistoryEntry.of(target, resolveRecipient(target, allParams), result);
pushHistory(session, entry);
return ResponseEntity.ok(entry);
}
/** 상태 폴링(AJAX, 3초 간격) — 별도 테이블 없이 PTL_MESSAGE_REQUEST 만 재조회한다. */
@GetMapping("/status")
@ResponseBody
public ResponseEntity<?> status(HttpSession session) {
ResponseEntity<?> denied = guardResponse();
if (denied != null) {
return denied;
}
List<NotiTestHistoryEntry> history = getHistory(session);
Set<String> ids = history.stream()
.flatMap(h -> h.getRequestIds().stream())
.collect(Collectors.toSet());
if (!ids.isEmpty()) {
Map<String, MessageRequest> byId = messageRequestRepository.findAllById(ids).stream()
.collect(Collectors.toMap(MessageRequest::getId, Function.identity()));
history.forEach(h -> h.refreshStatus(byId));
session.setAttribute(SESSION_HISTORY_KEY, history);
}
return ResponseEntity.ok(history);
}
private ResponseEntity<?> guardResponse() {
if (!notiTestProperties.isEnabled()) {
return ResponseEntity.notFound().build();
}
if (!isAllowedForCurrentUser()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return null;
}
private boolean isAllowedForCurrentUser() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String email = user == null ? null : user.getEmailAddr();
boolean allowed = notiTestProperties.isAllowed(email);
if (!allowed) {
log.warn("알림 테스트 페이지 접근 거부 - email: {}", email);
}
return allowed;
}
private Map<NotiTestTarget.Category, List<NotiTestTarget>> groupByCategory() {
return Arrays.stream(NotiTestTarget.values())
.collect(Collectors.groupingBy(NotiTestTarget::getCategory, LinkedHashMap::new, Collectors.toList()));
}
private String resolveRecipient(NotiTestTarget target, Map<String, String> formParams) {
if (target.getStrategy() == NotiTestTarget.Strategy.BROADCAST) {
return "실제 담당자 전원";
}
String phone = formParams.get("recipientPhone");
String email = formParams.get("recipientEmail");
String messengerId = formParams.get("recipientMessengerId");
if (target.getCategory() == NotiTestTarget.Category.EMAIL && StringUtils.hasText(email)) {
return email;
}
if (StringUtils.hasText(phone)) {
return phone;
}
if (StringUtils.hasText(email)) {
return email;
}
if (StringUtils.hasText(messengerId)) {
return messengerId;
}
return "-";
}
@SuppressWarnings("unchecked")
private void pushHistory(HttpSession session, NotiTestHistoryEntry entry) {
List<NotiTestHistoryEntry> history = (List<NotiTestHistoryEntry>) session.getAttribute(SESSION_HISTORY_KEY);
history = (history == null) ? new ArrayList<>() : new ArrayList<>(history);
history.add(0, entry);
if (history.size() > MAX_HISTORY) {
history = new ArrayList<>(history.subList(0, MAX_HISTORY));
}
session.setAttribute(SESSION_HISTORY_KEY, history);
}
@SuppressWarnings("unchecked")
private List<NotiTestHistoryEntry> getHistory(HttpSession session) {
List<NotiTestHistoryEntry> history = (List<NotiTestHistoryEntry>) session.getAttribute(SESSION_HISTORY_KEY);
return (history == null) ? new ArrayList<>() : history;
}
}
@@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -42,38 +43,43 @@ public class SwingMessageWriter {
private final SwingNotifyProperties properties; private final SwingNotifyProperties properties;
@Transactional @Transactional
public void write(MessageCode code, Map<String, Object> params) { public List<MessageRequest> write(MessageCode code, Map<String, Object> params) {
String role = properties.getTargetRole(); String role = properties.getTargetRole();
List<UserInfo> staffs = swingStaffRepository.findByRole(role); List<UserInfo> staffs = swingStaffRepository.findByRole(role);
if (staffs == null || staffs.isEmpty()) { if (staffs == null || staffs.isEmpty()) {
log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name()); log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
return; return new ArrayList<>();
} }
MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null); MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
if (template == null) { if (template == null) {
log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name()); log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
return; return new ArrayList<>();
} }
if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) { if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}", log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
code.name(), template.getEnableMessenger()); code.name(), template.getEnableMessenger());
return; return new ArrayList<>();
} }
SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds(); SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
EmployeeIdPolicy policy = properties.getNonEmployeePolicy(); EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
List<MessageRequest> created = new ArrayList<>();
for (UserInfo staff : staffs) { for (UserInfo staff : staffs) {
try { try {
writeOne(code, template, params, staff, umsIds, policy); MessageRequest saved = writeOne(code, template, params, staff, umsIds, policy);
if (saved != null) {
created.add(saved);
}
} catch (Exception e) { } catch (Exception e) {
log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e); log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
} }
} }
return created;
} }
private void writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params, private MessageRequest writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds, UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
EmployeeIdPolicy policy) { EmployeeIdPolicy policy) {
@@ -83,10 +89,10 @@ public class SwingMessageWriter {
if (!properties.isEmployeeId(messengerId)) { if (!properties.isEmployeeId(messengerId)) {
switch (policy) { switch (policy) {
case REJECT: case REJECT:
return; return null;
case REJECT_LOG: case REJECT_LOG:
log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name()); log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
return; return null;
case SKIP: case SKIP:
log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}", log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
STATUS_SKIPPED, messengerId, code.name()); STATUS_SKIPPED, messengerId, code.name());
@@ -124,8 +130,9 @@ public class SwingMessageWriter {
request.setRequestStatus(requestStatus); request.setRequestStatus(requestStatus);
// email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다. // email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
messageRequestRepository.save(request); MessageRequest saved = messageRequestRepository.save(request);
log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus); log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
return saved;
} }
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */ /** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
@@ -2,14 +2,18 @@ package com.eactive.apim.portal.djb.testcleanup.controller;
import com.eactive.apim.portal.common.internal.InternalApiTokenService; import com.eactive.apim.portal.common.internal.InternalApiTokenService;
import com.eactive.apim.portal.common.util.IpAddressMatcher; import com.eactive.apim.portal.common.util.IpAddressMatcher;
import com.eactive.apim.portal.djb.testcleanup.service.GroupRestrictedIncidentFixture;
import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService; import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult; import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService; import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -17,14 +21,17 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
* Playwright E2E 테스트 정리용 내부 API — local(개인 dev 프로필, 항상 {@code dev} 동반 활성화)과 * Playwright E2E 테스트 정리용 내부 API — 전용 {@code playwright} 프로필에서만 존재한다
* 배포된 dev 서버 양쪽에서만 존재한다({@code @Profile("dev")}, stage/prod 는 빈 자체가 없어 404). * ({@code @Profile("playwright")}). 개인 local 프로필·배포된 dev 서버가 각자
* {@code spring.profiles.include: playwright} 로 이 프로필을 동반 활성화하며, stage/prod 는
* 이를 포함하지 않아 빈 자체가 없어 404.
* *
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p> * <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
* <ol> * <ol>
@@ -41,7 +48,7 @@ import org.springframework.web.bind.annotation.RestController;
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre> * 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
*/ */
@Slf4j @Slf4j
@Profile("dev") @Profile("playwright")
@RestController @RestController
@RequestMapping("/internal/test-cleanup") @RequestMapping("/internal/test-cleanup")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -103,6 +110,356 @@ public class TestCleanupInternalController {
return ResponseEntity.ok(body); return ResponseEntity.ok(body);
} }
/**
* Playwright 실행 전 동일 이메일 계정의 존재 여부를 확인한다. 조회 전용이며 삭제하지 않는다.
*/
@GetMapping("/user/exists")
public ResponseEntity<Map<String, Object>> checkUserExists(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.checkUserExists(email);
log.info("테스트 정리(user exists) 조회 - email: {}, found: {}, from: {}",
email, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("userFound", result.isFound());
return ResponseEntity.ok(body);
}
/**
* 2000 실행 전 법인 계정의 실제 선행 상태를 조회한다. 조회 전용이며 cleanup을 수행하지 않는다.
* API 신청 권한의 최종 렌더링 여부는 보안 권한 매핑에 따르므로, E2E는 이 응답으로 데이터 선행조건을
* 확인한 다음 /clients 화면의 생성 버튼 노출까지 함께 검증한다.
*/
@GetMapping("/user/status")
public ResponseEntity<Map<String, Object>> getUserStatus(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
Optional<PortalUser> userOpt = testCleanupService.findUserForStatus(email);
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userFound", userOpt.isPresent());
if (userOpt.isPresent()) {
PortalUser user = userOpt.get();
PortalOrg org = user.getPortalOrg();
body.put("userId", user.getId());
body.put("roleCode", user.getRoleCode() == null ? null : user.getRoleCode().name());
body.put("userStatus", user.getUserStatus() == null ? null : user.getUserStatus().name());
body.put("userApprovalStatus", user.getApprovalStatus() == null ? null : user.getApprovalStatus().name());
body.put("authCompletedYn", user.getAuthCompletedYn());
body.put("orgId", org == null ? null : org.getId());
body.put("orgStatus", org == null || org.getOrgStatus() == null ? null : org.getOrgStatus().name());
body.put("orgApprovalStatus", org == null || org.getApprovalStatus() == null
? null : org.getApprovalStatus().name());
}
log.info("테스트 정리(user status) 조회 - email: {}, found: {}, from: {}",
email, userOpt.isPresent(), request.getRemoteAddr());
return ResponseEntity.ok(body);
}
/**
* 2000 재실행 전 동일 이름으로 남은 테스트 클라이언트 신청만 취소한다.
* 대상은 {@code 단위테스트앱-} 접두사와 이메일 소속 법인으로 이중 한정한다.
*/
@PostMapping("/app-request")
public ResponseEntity<Map<String, Object>> cancelPendingTestAppRequests(
@RequestParam String email, @RequestParam String clientName, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(clientName)) {
return badRequest("email 과 clientName 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.cancelPendingTestAppRequests(email, clientName);
log.info("테스트 정리(app request) 실행 - email: {}, clientName: {}, found: {}, from: {}",
email, clientName, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("clientName", clientName);
body.put("appRequestFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4020 재실행 전, 해당 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다.
* 대상은 이메일(작성자)과 {@code 단위테스트*} 제목 접두사로 이중 한정한다.
*/
@PostMapping("/partnership")
public ResponseEntity<Map<String, Object>> deletePartnershipApplications(
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
return badRequest("email 과 subjectPrefix 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deletePartnershipApplicationsByEmail(email, subjectPrefix);
log.info("테스트 정리(partnership) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("subjectPrefix", subjectPrefix);
body.put("partnershipFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4010 재실행 전(또는 종료 후 최종 정리), 해당 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
* 대상은 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사로 이중 한정한다.
*/
@PostMapping("/inquiry")
public ResponseEntity<Map<String, Object>> deleteInquiries(
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
return badRequest("email 과 subjectPrefix 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteInquiriesByEmail(email, subjectPrefix);
log.info("테스트 정리(inquiry) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("subjectPrefix", subjectPrefix);
body.put("inquiryFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4010 실행 전, 지정 이메일 계정을 관리자 이메일로 조회한 법인의 ROLE_CORP_USER(법인개발자)로 준비한다.
* 이미 존재하면 재소속+비밀번호 재설정(heal), 없으면 신규 생성(create) — 두 경우 모두 응답한 비밀번호로
* 곧바로 로그인 가능한 상태가 된다.
*/
@PostMapping("/corp-developer")
public ResponseEntity<Map<String, Object>> ensureCorpDeveloper(
@RequestParam String managerEmail, @RequestParam String email, @RequestParam String password,
@RequestParam String mobile, @RequestParam String userName, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(managerEmail) || StringUtils.isBlank(email) || StringUtils.isBlank(password)
|| StringUtils.isBlank(mobile) || StringUtils.isBlank(userName)) {
return badRequest("managerEmail, email, password, mobile, userName 은 모두 필수입니다.");
}
TestCleanupResult result = testCleanupService.ensureTestCorpDeveloper(managerEmail, email, password, mobile, userName);
boolean created = result.getDeletedCounts().getOrDefault("PTL_USER_CREATED", 0L) > 0;
log.info("테스트 정리(corp-developer) 실행 - managerEmail: {}, email: {}, created: {}, from: {}",
managerEmail, email, created, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("managerEmail", managerEmail);
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("created", created);
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 그대로). 화면 가입 절차 없이 즉시 로그인
* 가능한 값으로 되돌리는 용도 — 공용 시드 계정의 비밀번호가 정책과 안 맞게 바뀐 경우 등.
*/
@PostMapping("/password")
public ResponseEntity<Map<String, Object>> resetPassword(
@RequestParam String email, @RequestParam String password, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(password)) {
return badRequest("email 과 password 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.resetTestPassword(email, password);
log.info("테스트 정리(password) 실행 - email: {}, found: {}, from: {}",
email, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userFound", result.isFound());
return ResponseEntity.ok(body);
}
/**
* API Status 그룹공개(법인전용) 필터 검증용 "다른 법인" 계정을 준비한다. 지정 사업자번호의
* 법인이 없으면 새로 만들고(승인 절차 없이 즉시 로그인 가능), 있으면 매니저 계정을 재소속·
* 비밀번호 재설정으로 되돌린다(heal). 정리는 기존 {@code /internal/test-cleanup/org} 를
* 그대로 재사용한다(같은 compRegNo).
*/
@PostMapping("/secondary-org")
public ResponseEntity<Map<String, Object>> ensureSecondaryOrg(
@RequestParam String compRegNo, @RequestParam String orgName, @RequestParam String managerEmail,
@RequestParam String password, @RequestParam String mobile, @RequestParam String userName,
HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(compRegNo) || StringUtils.isBlank(orgName) || StringUtils.isBlank(managerEmail)
|| StringUtils.isBlank(password) || StringUtils.isBlank(mobile) || StringUtils.isBlank(userName)) {
return badRequest("compRegNo, orgName, managerEmail, password, mobile, userName 은 모두 필수입니다.");
}
TestCleanupResult result =
testCleanupService.ensureSecondaryTestOrg(compRegNo, orgName, managerEmail, password, mobile, userName);
log.info("테스트 정리(secondary-org) 실행 - compRegNo: {}, managerEmail: {}, from: {}",
compRegNo, managerEmail, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("compRegNo", compRegNo);
body.put("managerEmail", managerEmail);
body.put("orgId", result.getTargetId());
body.put("orgCreated", result.getDeletedCounts().getOrDefault("PTL_ORG_CREATED", 0L) > 0);
body.put("userCreated", result.getDeletedCounts().getOrDefault("PTL_USER_CREATED", 0L) > 0);
return ResponseEntity.ok(body);
}
/**
* API Status 그룹공개 필터 검증용 픽스처를 만든다 — {@code managerEmail} 소속 법인으로 현재
* 게시된 API 중 하나(동적 선택)를 제한하고, 그 API 에 영향을 준 종결 인시던트 1건을 만든다.
*/
@PostMapping("/group-restricted-incident")
public ResponseEntity<Map<String, Object>> ensureGroupRestrictedIncident(
@RequestParam String managerEmail, @RequestParam String subject,
@RequestParam(required = false) String summary, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(managerEmail) || StringUtils.isBlank(subject)) {
return badRequest("managerEmail 과 subject 는 필수입니다.");
}
GroupRestrictedIncidentFixture fixture;
try {
fixture = testCleanupService.ensureGroupRestrictedIncident(managerEmail, subject, summary);
} catch (IllegalArgumentException | IllegalStateException e) {
return badRequest(e.getMessage());
}
log.info("테스트 정리(group-restricted-incident) 실행 - managerEmail: {}, apiId: {}, incidentId: {}, from: {}",
managerEmail, fixture.getApiId(), fixture.getIncidentId(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("apiId", fixture.getApiId());
body.put("apiName", fixture.getApiName());
body.put("orgId", fixture.getOrgId());
body.put("incidentId", fixture.getIncidentId());
body.put("previousDisplayOrg", fixture.getPreviousDisplayOrg());
body.put("previousDisplayRoleCode", fixture.getPreviousDisplayRoleCode());
return ResponseEntity.ok(body);
}
/**
* {@code /group-restricted-incident} 로 만든 픽스처를 원복한다 — 정확한 apiId/incidentId 를
* 요구해 와일드카드 삭제가 없다.
*/
@PostMapping("/group-restricted-incident/revert")
public ResponseEntity<Map<String, Object>> revertGroupRestrictedIncident(
@RequestParam String apiId, @RequestParam Long incidentId,
@RequestParam(required = false) String previousDisplayOrg,
@RequestParam(required = false) String previousDisplayRoleCode, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(apiId) || incidentId == null) {
return badRequest("apiId 와 incidentId 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.revertGroupRestrictedIncident(
apiId, incidentId, previousDisplayOrg, previousDisplayRoleCode);
log.info("테스트 정리(group-restricted-incident revert) 실행 - apiId: {}, incidentId: {}, found: {}, from: {}",
apiId, incidentId, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("apiId", apiId);
body.put("incidentId", incidentId);
body.put("incidentFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 남아 있는 초대 레코드를 삭제한다. 1020 재실행 전 초대중 중복을 정리하는 용도다.
*/
@PostMapping("/invitation")
public ResponseEntity<Map<String, Object>> deleteInvitations(
@RequestParam String mobile, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(mobile)) {
return badRequest("mobile 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteInvitationsByMobile(mobile);
log.info("테스트 정리(invitation) 실행 - mobile: {}, found: {}, from: {}",
mobile, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("mobile", mobile);
body.put("invitationFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 찾은 계정을 법인 소속에서 제외한다. 계정 자체는 삭제하지 않는다.
*/
@PostMapping("/membership")
public ResponseEntity<Map<String, Object>> detachMembership(
@RequestParam String mobile, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(mobile)) {
return badRequest("mobile 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.detachUsersFromOrgByMobile(mobile);
log.info("테스트 정리(membership) 실행 - mobile: {}, found: {}, from: {}",
mobile, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("mobile", mobile);
body.put("membershipFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
@PostMapping("/orphans") @PostMapping("/orphans")
public ResponseEntity<Map<String, Object>> cleanOrphans(HttpServletRequest request) { public ResponseEntity<Map<String, Object>> cleanOrphans(HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request); ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
@@ -0,0 +1,19 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import lombok.Value;
/**
* {@link TestCleanupService#ensureGroupRestrictedIncident} 결과. {@link TestCleanupResult}는
* "대상 존재 여부 + 테이블별 삭제건수"(Long) 형태라 이 작업의 응답(선택된 API/법인/원래 공개범위)엔
* 맞지 않아 전용 타입을 둔다. {@code previousDisplayOrg}/{@code previousDisplayRoleCode}는
* 원복({@link TestCleanupService#revertGroupRestrictedIncident})에 그대로 되돌려줘야 한다.
*/
@Value
public class GroupRestrictedIncidentFixture {
String apiId;
String apiName;
String orgId;
Long incidentId;
String previousDisplayOrg;
String previousDisplayRoleCode;
}
@@ -11,11 +11,11 @@ import org.springframework.transaction.annotation.Transactional;
/** /**
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을 * Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
* 13개 테이블에서 스윕 삭제한다. local(dev 동반 활성화)·dev 서버에서만 동작한다({@link TestCleanupService} * 13개 테이블에서 스윕 삭제한다. 전용 {@code playwright} 프로필에서만 동작한다({@link TestCleanupService}
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다. * 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
*/ */
@Slf4j @Slf4j
@Profile("dev") @Profile("playwright")
@Service @Service
@Transactional @Transactional
public class OrphanCleanupService { public class OrphanCleanupService {
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Repository;
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용, * 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다. * 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
*/ */
@Profile("dev") @Profile("playwright")
@Repository @Repository
class TestCleanupNativeQueries { class TestCleanupNativeQueries {
@@ -1,41 +1,74 @@
package com.eactive.apim.portal.djb.testcleanup.service; package com.eactive.apim.portal.djb.testcleanup.service;
import com.eactive.apim.portal.app.repository.CredentialRepository; import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository; import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository; import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository; import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
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.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
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.djb.community.qna.comment.repository.InquiryCommentRepository; import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository; import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository; import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository; import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest; import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.service.WebhookService; import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg; import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository; import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository; import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository; import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.user.repository.UserLogRepository; import com.eactive.apim.portal.user.repository.UserLogRepository;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles; import org.springframework.core.env.Profiles;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/** /**
* Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과 * Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과
* 그에 딸린 데이터를 하드 삭제한다. local(dev 동반 활성화)·dev 서버에서만 존재하는 빈이며, * 그에 딸린 데이터를 하드 삭제한다. 전용 {@code playwright} 프로필에서만 존재하는 빈이며,
* stage/prod 는 {@code @Profile("dev")} 로 빈 자체가 등록되지 않는다. * local/dev 프로필이 {@code spring.profiles.include: playwright} 로 동반 활성화한다.
* stage/prod 는 이를 포함하지 않아 {@code @Profile("playwright")} 로 빈 자체가 등록되지 않는다.
* *
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p> * <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
*/ */
@Slf4j @Slf4j
@Profile("dev") @Profile("playwright")
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional @Transactional
@@ -52,11 +85,33 @@ public class TestCleanupService {
private final UserPasswordHistoryRepository userPasswordHistoryRepository; private final UserPasswordHistoryRepository userPasswordHistoryRepository;
private final UserLogRepository userLogRepository; private final UserLogRepository userLogRepository;
private final CredentialRepository credentialRepository; private final CredentialRepository credentialRepository;
private final AppRequestRepository appRequestRepository;
private final ApprovalService approvalService;
private final WebhookRequestRepository webhookRequestRepository; private final WebhookRequestRepository webhookRequestRepository;
private final WebhookRequestApiRepository webhookRequestApiRepository; private final WebhookRequestApiRepository webhookRequestApiRepository;
private final WebhookRequestEventRepository webhookRequestEventRepository; private final WebhookRequestEventRepository webhookRequestEventRepository;
private final WebhookService webhookService; private final WebhookService webhookService;
private final UserInvitationRepository userInvitationRepository;
private final FileService fileService;
private final TestCleanupNativeQueries nativeQueries; private final TestCleanupNativeQueries nativeQueries;
private final PortalUserService portalUserService;
private final PasswordEncoder passwordEncoder;
private final ApiSpecInfoRepository apiSpecInfoRepository;
private final DjbApistatusIncidentRepository djbApistatusIncidentRepository;
private final DjbApistatusIncidentApiRepository djbApistatusIncidentApiRepository;
private final ApiSearchFacade apiSearchFacade;
/** notice-incident.spec.ts 기본 영향 API — 동시 실행 충돌을 피하려 자동선택에서 제외한다 */
private static final String RESERVED_DEFAULT_API_ID = "ANSTOSOBP00001S2";
/**
* 이 내부 API 는 Spring Security 인증 없이(IP+토큰 가드만으로) 호출되므로
* {@code AuditorAwareImpl}(SecurityUtil.getPortalAuthenticatedUser() 기반)이 항상
* empty 를 반환해 {@code @CreatedBy} 자동 채움이 동작하지 않는다. PTL_ORG/PTL_USER 는
* created_by 가 nullable 이라 문제없이 넘어가지만, DJB_APISTATUS_INCIDENT(_API) 는
* NOT NULL 이라 명시적으로 채워야 한다(ORA-01400).
*/
private static final String TEST_FIXTURE_CREATED_BY = "PLAYWRIGHT_TEST";
/** /**
* 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다. * 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다.
@@ -65,6 +120,12 @@ public class TestCleanupService {
assertNonProdProfile(); assertNonProdProfile();
Optional<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo); Optional<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo);
// 가입 화면은 000-00-00001처럼 입력받지만 DB에는 숫자만 저장되는 환경도 있다.
// cleanup API는 두 형식을 모두 받아 이전 E2E 실행 법인을 빠짐없이 정리해야 한다.
String digitsOnlyCompRegNo = compRegNo.replaceAll("\\D", "");
if (!orgOpt.isPresent() && !digitsOnlyCompRegNo.isEmpty() && !digitsOnlyCompRegNo.equals(compRegNo)) {
orgOpt = portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo);
}
if (!orgOpt.isPresent()) { if (!orgOpt.isPresent()) {
return TestCleanupResult.notFound(); return TestCleanupResult.notFound();
} }
@@ -72,6 +133,8 @@ public class TestCleanupService {
String orgId = org.getId(); String orgId = org.getId();
TestCleanupResult result = TestCleanupResult.found(orgId); TestCleanupResult result = TestCleanupResult.found(orgId);
result.put("PTL_USER_INVITATION", userInvitationRepository.deleteByOrgId(orgId));
List<PortalUser> users = portalUserRepository.findAllByPortalOrg_Id(orgId); List<PortalUser> users = portalUserRepository.findAllByPortalOrg_Id(orgId);
for (PortalUser user : users) { for (PortalUser user : users) {
result.merge(deleteUserCascadeInternal(user)); result.merge(deleteUserCascadeInternal(user));
@@ -117,6 +180,257 @@ public class TestCleanupService {
return result; return result;
} }
/**
* 이메일 기준으로 테스트 계정 존재 여부만 조회한다. 삭제나 데이터 변경은 수행하지 않는다.
*/
@Transactional(readOnly = true)
public TestCleanupResult checkUserExists(String email) {
assertNonProdProfile();
return portalUserRepository.findPortalUserByEmailAddr(email)
.map(user -> TestCleanupResult.found(user.getId()))
.orElseGet(TestCleanupResult::notFound);
}
/**
* E2E 실행 가능 여부를 판단하기 위한 계정 조회다. 삭제나 상태 변경은 수행하지 않는다.
* 호출자는 법인 소속/승인/역할을 확인해 2000 선행 시나리오(1010/1011)가 갖춰졌는지 판단한다.
*/
@Transactional(readOnly = true)
public Optional<PortalUser> findUserForStatus(String email) {
assertNonProdProfile();
return portalUserRepository.findPortalUserByEmailAddr(email);
}
/**
* 동일 이름으로 재실행할 때 남은 테스트 APP 신청만 취소한다.
* 운영 데이터 오삭제를 막기 위해 {@code 단위테스트앱-} 접두사, 이메일의 법인 소속, 정확한 클라이언트명 세 조건을 모두 요구한다.
*/
public TestCleanupResult cancelPendingTestAppRequests(String email, String clientName) {
assertNonProdProfile();
if (clientName == null || !clientName.startsWith("단위테스트앱-")) {
throw new IllegalArgumentException("테스트 앱 이름(단위테스트앱-*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent() || userOpt.get().getPortalOrg() == null) {
return TestCleanupResult.notFound();
}
TestCleanupResult result = TestCleanupResult.notFound();
long cancelled = 0L;
for (AppRequest request : appRequestRepository.findAllByOrgAndClientName(userOpt.get().getPortalOrg(), clientName)) {
if (request.getApproval() == null || !(request.getApproval().getApprovalStatus() instanceof RequestedState
|| request.getApproval().getApprovalStatus() instanceof ProcessingState)) {
continue;
}
approvalService.cancelAppApproval(request);
result = TestCleanupResult.found(request.getId());
cancelled++;
}
result.put("PTL_APP_REQUEST_CANCELLED", cancelled);
return result;
}
/**
* 휴대폰 번호로 남은 초대 레코드를 전부 삭제한다. 1020 재실행 전 PENDING 초대 중복을 방지한다.
*/
public TestCleanupResult deleteInvitationsByMobile(String mobile) {
assertNonProdProfile();
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile == null) {
return TestCleanupResult.notFound();
}
long deleted = userInvitationRepository.deleteByInvitationMobile(normalizedMobile);
TestCleanupResult result = deleted > 0 ? TestCleanupResult.found(normalizedMobile) : TestCleanupResult.notFound();
result.put("PTL_USER_INVITATION", deleted);
return result;
}
/**
* 휴대폰 번호로 찾은 계정을 법인 소속에서만 제외해 개인회원으로 되돌린다.
* 1020이 수락/소속 제외 전에 중단된 경우, 1000 선행 개인회원은 보존하면서 재초대 가능 상태로 복구한다.
*/
public TestCleanupResult detachUsersFromOrgByMobile(String mobile) {
assertNonProdProfile();
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile == null) {
return TestCleanupResult.notFound();
}
TestCleanupResult result = TestCleanupResult.notFound();
for (PortalUser user : portalUserRepository.findAllByMobileNumber(normalizedMobile)) {
if (user.getPortalOrg() == null) {
continue;
}
user.setPortalOrg(null);
user.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(user);
result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_ORG_MEMBERSHIP", 1L);
}
return result;
}
/**
* 4020 재실행 전, 특정 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다(첨부파일 포함).
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 단위테스트} 로 시작하는 제목 접두사를 모두 요구한다.
*/
public TestCleanupResult deletePartnershipApplicationsByEmail(String email, String subjectPrefix) {
assertNonProdProfile();
if (subjectPrefix == null || !subjectPrefix.startsWith("단위테스트")) {
throw new IllegalArgumentException("테스트 글 제목 접두사(단위테스트*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
List<PartnershipApplication> targets =
partnershipApplicationRepository.findAllByCreatedByAndBizSubjectStartingWith(user.getId(), subjectPrefix);
if (targets.isEmpty()) {
TestCleanupResult empty = TestCleanupResult.notFound();
empty.put("PTL_PARTNERSHIP_APPLICATION", 0L);
return empty;
}
long files = 0L;
for (PartnershipApplication target : targets) {
if (target.getFileId() != null && !target.getFileId().trim().isEmpty()) {
fileService.deleteFile(target.getFileId());
files++;
}
}
partnershipApplicationRepository.deleteAll(targets);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_PARTNERSHIP_APPLICATION", (long) targets.size());
result.put("PTL_FILE_INFO", files);
log.info("테스트 정리 - 피드백/개선요청 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
return result;
}
/**
* 4010 재실행 전(또는 종료 후 최종 정리) 특정 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사를 모두 요구한다.
* 답변완료(RESPONDED)로 전환된 문의는 포털 자가삭제(PENDING + 작성자 전용)가 막히므로, 4010 종료 시점의
* 최종 정리도 이 API를 재사용한다.
*/
public TestCleanupResult deleteInquiriesByEmail(String email, String subjectPrefix) {
assertNonProdProfile();
if (subjectPrefix == null || !subjectPrefix.startsWith("새글 작성 테스트")) {
throw new IllegalArgumentException("테스트 문의글 제목 접두사(새글 작성 테스트*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
List<Inquiry> targets =
inquiryRepository.findAllByInquirer_IdAndInquirySubjectStartingWith(user.getId(), subjectPrefix);
if (targets.isEmpty()) {
TestCleanupResult empty = TestCleanupResult.notFound();
empty.put("PTL_INQUIRY", 0L);
empty.put("PTL_INQUIRY_COMMENT", 0L);
return empty;
}
List<String> ids = targets.stream().map(Inquiry::getId).collect(Collectors.toList());
long comments = inquiryCommentRepository.deleteByInquiry_IdIn(ids);
inquiryRepository.deleteAll(targets);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY", (long) targets.size());
result.put("PTL_INQUIRY_COMMENT", comments);
log.info("테스트 정리 - 문의글 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
return result;
}
/**
* 4010 재실행 안정성을 위한 테스트 전용 API. 지정 이메일 계정을 관리자 이메일로 조회한 법인의
* ROLE_CORP_USER(법인개발자)로 만든다 — 이미 존재하면(다른 시나리오/수작업이 소속을 해제했더라도)
* 재소속·비밀번호 재설정으로 로그인 가능 상태를 보장하고(heal), 계정이 없으면 새로 만든다(create).
* 계정 생성/소속 전환 로직은 초대 수락 경로({@link PortalUserService#registerInvitedUser},
* {@link PortalUserService#updateUserToCorpUser})를 그대로 재사용한다.
*/
public TestCleanupResult ensureTestCorpDeveloper(
String managerEmail, String email, String password, String mobile, String userName) {
assertNonProdProfile();
PortalUser manager = portalUserRepository.findPortalUserByEmailAddr(managerEmail)
.orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다: " + managerEmail));
PortalOrg org = manager.getPortalOrg();
if (org == null) {
throw new IllegalArgumentException("관리자 계정이 법인에 소속되어 있지 않습니다: " + managerEmail);
}
Optional<PortalUser> existing = portalUserRepository.findPortalUserByEmailAddr(email);
boolean created;
PortalUser user;
if (existing.isPresent()) {
user = existing.get();
portalUserService.updateUserToCorpUser(user, org.getId());
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
user.setApprovalStatus(PortalUserEnums.ApprovalStatus.COMPLETED);
user.setAccountLockYn("N");
user.setLoginFailureCount(0);
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile != null) {
user.setMobileNumber(normalizedMobile);
}
portalUserRepository.save(user);
created = false;
} else {
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
dto.setLoginId(email);
dto.setUserName(userName);
dto.setPassword(password);
dto.setMobileNumber(mobile);
user = portalUserService.registerInvitedUser(dto, org.getId());
created = true;
}
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_CREATED", created ? 1L : 0L);
result.put("PTL_USER_ATTACHED", created ? 0L : 1L);
log.info("테스트 정리 - 법인개발자 계정 준비 완료: email={}, orgId={}, created={}", email, org.getId(), created);
return result;
}
/**
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 건드리지 않음). 계정이 아예 없으면 not-found.
* 4010 등에서 재사용하는 공용 계정(예: REG_CORP_LOGIN_ID)의 비밀번호가 클라이언트 정책(길이/복잡도)에
* 안 맞게 바뀌었을 때, 화면 가입 절차 없이 즉시 로그인 가능한 값으로 되돌리는 용도.
*/
public TestCleanupResult resetTestPassword(String email, String password) {
assertNonProdProfile();
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
user.setLoginFailureCount(0);
user.setAccountLockYn("N");
portalUserRepository.save(user);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_PASSWORD_RESET", 1L);
log.info("테스트 정리 - 비밀번호 재설정 완료: email={}", email);
return result;
}
private TestCleanupResult deleteUserCascadeInternal(PortalUser user) { private TestCleanupResult deleteUserCascadeInternal(PortalUser user) {
TestCleanupResult result = TestCleanupResult.found(user.getId()); TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId())); result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId()));
@@ -133,6 +447,182 @@ public class TestCleanupService {
return result; return result;
} }
/**
* API Status 그룹공개(법인전용) 필터를 Playwright 로 검증하려면 "다른 법인" 로그인 계정이
* 하나 더 필요하다. 지정 사업자번호(compRegNo)의 법인이 없으면 새로 만들고(관리자 승인 절차
* 없이 바로 ACTIVE/COMPLETED — {@link PortalUserService#createUserWithOrg} 가 신규 법인가입
* 경로에서 쓰는 것과 같은 즉시활성 경로를 재사용), 있으면 매니저 계정을 재소속·비밀번호
* 재설정으로 로그인 가능한 상태로 되돌린다(heal). 정리는 새 API 를 만들 필요 없이 기존
* {@link #deleteOrgCascade(String)}(compRegNo)를 그대로 재사용한다.
*/
public TestCleanupResult ensureSecondaryTestOrg(
String compRegNo, String orgName, String managerEmail, String password, String mobile, String userName) {
assertNonProdProfile();
Optional<PortalOrg> existingOrg = portalOrgRepository.findByCompRegNo(compRegNo);
boolean orgCreated = !existingOrg.isPresent();
PortalOrg org;
if (existingOrg.isPresent()) {
org = existingOrg.get();
} else {
PortalOrg created = new PortalOrg();
created.setId(UUID.randomUUID().toString());
created.setCompRegNo(compRegNo);
created.setOrgName(orgName);
created.setOrgStatus(PortalOrgEnums.OrgStatus.ACTIVE);
created.setApprovalStatus(PortalOrgEnums.ApprovalStatus.COMPLETED);
org = portalOrgRepository.save(created);
}
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(managerEmail);
boolean userCreated;
PortalUser user;
if (existingUser.isPresent()) {
user = existingUser.get();
user.setPortalOrg(org);
user.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
user.setApprovalStatus(PortalUserEnums.ApprovalStatus.COMPLETED);
user.setAccountLockYn("N");
user.setLoginFailureCount(0);
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile != null) {
user.setMobileNumber(normalizedMobile);
}
portalUserRepository.save(user);
userCreated = false;
} else {
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
dto.setLoginId(managerEmail);
dto.setUserName(userName);
dto.setPassword(password);
dto.setMobileNumber(mobile);
// emailVerified=true — 가입 즉시 ACTIVE/COMPLETED (승인 대기 없음), registrationType="corporate" → ROLE_CORP_MANAGER
user = portalUserService.createUserWithOrg(dto, org, "corporate", true);
userCreated = true;
}
TestCleanupResult result = TestCleanupResult.found(org.getId());
result.put("PTL_ORG_CREATED", orgCreated ? 1L : 0L);
result.put("PTL_USER_CREATED", userCreated ? 1L : 0L);
log.info("테스트 정리 - 보조 법인 준비 완료: compRegNo={}, orgId={}, orgCreated={}, managerEmail={}, userCreated={}",
compRegNo, org.getId(), orgCreated, managerEmail, userCreated);
return result;
}
/**
* API Status 그룹공개 필터 Playwright 검증용 픽스처를 만든다. {@code managerEmail} 로 제한 대상
* 법인을 역산하므로(환경마다 org id 를 몰라도 됨), 이 환경에 실제로 게시된(displayYn=Y) API 중
* 하나를 동적으로 골라 그 API 의 {@code displayOrg} 를 해당 법인으로 제한한 뒤, 그 API 에 영향을
* 준 것으로 기록된 종결(RESOLVED) 인시던트 1건을 만든다 — API Status 메인(P6)과 전체 이력
* (P9/P10) 양쪽에서 동시에 검증 가능하도록 최근 시각으로 둔다.
*
* <p>특정 api_id 를 하드코딩하지 않는다 — 환경(dev/local/stage)마다 카탈로그가 다르기 때문이다.
* {@code notice-incident.spec.ts} 가 기본으로 쓰는 API 는 동시실행 충돌을 피하려 후보에서
* 제외한다(있으면).</p>
*
* <p>후보는 {@code PTL_API_SPEC_INFO.DISPLAY_YN='Y'} 만으로 고르지 않는다 — 포털 카탈로그
* 노출은 그것만으로 결정되지 않고 AGWAPP.API_GROUP_API 매핑까지 있어야 한다
* ({@link ApiStatusCatalogService#getSelectableApis()} 주석 참고). 그 조건까지 반영된 실제
* 카탈로그 조회 경로({@link ApiSearchFacade#searchApis})를 그대로 재사용해야, 뽑은 API 가
* {@code ApiStatusCatalogService#getVisibleApiIdsForQuery()}(= 이 검증이 실제로 통제하는
* visibleApiIds)에도 반드시 잡힌다. 이 메서드는 비인증 컨텍스트에서 호출되므로
* {@code searchApis} 결과는 이미 "공개범위 제한 없음"만 걸러져 있다(비로그인 판정 —
* {@code ApiPermissionFilter.checkApiPermissions} 의 !isAuthenticated 분기).</p>
*/
@SuppressWarnings("unchecked")
public GroupRestrictedIncidentFixture ensureGroupRestrictedIncident(
String managerEmail, String subject, String summary) {
assertNonProdProfile();
PortalUser manager = portalUserRepository.findPortalUserByEmailAddr(managerEmail)
.orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다: " + managerEmail));
PortalOrg org = manager.getPortalOrg();
if (org == null) {
throw new IllegalArgumentException("관리자 계정이 법인에 소속되어 있지 않습니다: " + managerEmail);
}
Object apisObj = apiSearchFacade.searchApis(new ApiGroupSearch()).get("apis");
List<ApiSpecInfoDto> catalog = apisObj instanceof List ? (List<ApiSpecInfoDto>) apisObj : Collections.emptyList();
if (catalog.isEmpty()) {
throw new IllegalStateException(
"이 환경에 공개범위 제한 없이 카탈로그에 노출된 API가 하나도 없습니다. "
+ "API 그룹에 편성된 API Spec을 하나 이상 게시해야 이 검증을 진행할 수 있습니다.");
}
String candidateApiId = catalog.stream()
.map(ApiSpecInfoDto::getApiId)
.filter(StringUtils::isNotBlank)
.filter(apiId -> !RESERVED_DEFAULT_API_ID.equals(apiId))
.findFirst()
.orElseGet(() -> catalog.get(0).getApiId());
ApiSpecInfo target = apiSpecInfoRepository.findById(candidateApiId)
.orElseThrow(() -> new IllegalStateException("카탈로그 API 스펙을 찾지 못했습니다: " + candidateApiId));
String previousDisplayOrg = target.getDisplayOrg();
String previousDisplayRoleCode = target.getDisplayRoleCode();
target.setDisplayOrg(org.getId());
apiSpecInfoRepository.save(target);
LocalDateTime now = LocalDateTime.now();
DjbApistatusIncident incident = new DjbApistatusIncident();
incident.setKind(IncidentKind.INCIDENT);
incident.setState(IncidentState.RESOLVED);
incident.setTitle(subject);
incident.setSummary(StringUtils.defaultString(summary));
incident.setStartedAt(now.minusHours(1));
incident.setEndAt(now);
incident.setDraftYn("N");
incident.setCreatedBy(TEST_FIXTURE_CREATED_BY);
incident = djbApistatusIncidentRepository.save(incident);
DjbApistatusIncidentApi incidentApi = new DjbApistatusIncidentApi();
incidentApi.setIncidentId(incident.getIncidentId());
incidentApi.setApiId(target.getApiId());
incidentApi.setApiName(target.getApiName());
incidentApi.setCreatedBy(TEST_FIXTURE_CREATED_BY);
djbApistatusIncidentApiRepository.save(incidentApi);
log.info("테스트 정리 - 그룹전용 인시던트 준비 완료: apiId={}, orgId={}, incidentId={}, previousDisplayOrg={}",
target.getApiId(), org.getId(), incident.getIncidentId(), previousDisplayOrg);
return new GroupRestrictedIncidentFixture(target.getApiId(), target.getApiName(), org.getId(),
incident.getIncidentId(), previousDisplayOrg, previousDisplayRoleCode);
}
/**
* {@link #ensureGroupRestrictedIncident} 로 만든 픽스처를 원복한다 — 인시던트/영향API 행 삭제 +
* 대상 API 의 공개범위를 호출자가 넘긴 원래값으로 되돌린다. 정확한 {@code apiId}/{@code incidentId}
* 를 요구해 와일드카드 삭제가 없다.
*/
public TestCleanupResult revertGroupRestrictedIncident(
String apiId, Long incidentId, String previousDisplayOrg, String previousDisplayRoleCode) {
assertNonProdProfile();
Optional<DjbApistatusIncident> incidentOpt = djbApistatusIncidentRepository.findById(incidentId);
if (!incidentOpt.isPresent()) {
return TestCleanupResult.notFound();
}
djbApistatusIncidentApiRepository.deleteByIncidentId(incidentId);
djbApistatusIncidentRepository.deleteById(incidentId);
Optional<ApiSpecInfo> specOpt = apiSpecInfoRepository.findById(apiId);
if (specOpt.isPresent()) {
ApiSpecInfo spec = specOpt.get();
spec.setDisplayOrg(previousDisplayOrg);
spec.setDisplayRoleCode(previousDisplayRoleCode);
apiSpecInfoRepository.save(spec);
}
TestCleanupResult result = TestCleanupResult.found(String.valueOf(incidentId));
result.put("DJB_APISTATUS_INCIDENT", 1L);
result.put("DJB_APISTATUS_INCIDENT_API", 1L);
log.info("테스트 정리 - 그룹전용 인시던트 원복 완료: apiId={}, incidentId={}", apiId, incidentId);
return result;
}
private void assertNonProdProfile() { private void assertNonProdProfile() {
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) { if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다."); throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
@@ -5,15 +5,20 @@ import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService; import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver; import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO; import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO; import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider; import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
import com.eactive.apim.portal.djb.webhook.service.WebhookService; import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.djb.webhook.service.WebhookUrlValidator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.Valid; import javax.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -54,6 +59,8 @@ public class WebhookController {
private final WebhookEventTypeProvider eventTypeProvider; private final WebhookEventTypeProvider eventTypeProvider;
private final ApiServiceService apiServiceService; private final ApiServiceService apiServiceService;
private final AppServiceFacade appServiceFacade; private final AppServiceFacade appServiceFacade;
// 본인확인 연속 실패는 step-up 확인 페이지와 같은 카운터를 쓴다(화면을 바꿔가며 우회하지 못하게).
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
@ModelAttribute("webhookRegistration") @ModelAttribute("webhookRegistration")
public WebhookRegistrationDTO webhookRegistration() { public WebhookRegistrationDTO webhookRegistration() {
@@ -211,6 +218,7 @@ public class WebhookController {
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1"); ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll()); mav.addObject("eventTypes", eventTypeProvider.getAll());
mav.addObject("userSecretSet", webhook.getUserSecretMasked() != null && !webhook.getUserSecretMasked().isEmpty());
addStepModel(mav, 1); addStepModel(mav, 1);
return mav; return mav;
} }
@@ -297,33 +305,35 @@ public class WebhookController {
@PostMapping("/verify-secret") @PostMapping("/verify-secret")
@ResponseBody @ResponseBody
public Map<String, Object> verifySecret(@RequestParam String password) { public Map<String, Object> verifySecret(@RequestParam String password,
Map<String, Object> result = new HashMap<>(); HttpSession session, HttpServletRequest request, HttpServletResponse response) {
if (!verifyPassword(password)) { Map<String, Object> failResult = checkPassword(password, session, request, response);
result.put("success", false); if (failResult != null) {
result.put("message", "비밀번호가 일치하지 않습니다."); return failResult;
return result;
} }
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId()); Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) { if (!webhook.isPresent()) {
result.put("success", false); result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다."); result.put("message", "등록된 Webhook이 없습니다.");
return result; return result;
} }
Long webhookId = webhook.get().getId();
result.put("success", true); result.put("success", true);
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId())); result.put("secret", webhookService.getPlainSecret(webhookId, currentOrgId()));
result.put("userSecret", webhookService.getPlainUserSecret(webhookId, currentOrgId()));
return result; return result;
} }
@PostMapping("/regenerate-secret") @PostMapping("/regenerate-secret")
@ResponseBody @ResponseBody
public Map<String, Object> regenerateSecret(@RequestParam String password) { public Map<String, Object> regenerateSecret(@RequestParam String password,
Map<String, Object> result = new HashMap<>(); HttpSession session, HttpServletRequest request, HttpServletResponse response) {
if (!verifyPassword(password)) { Map<String, Object> failResult = checkPassword(password, session, request, response);
result.put("success", false); if (failResult != null) {
result.put("message", "비밀번호가 일치하지 않습니다."); return failResult;
return result;
} }
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId()); Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) { if (!webhook.isPresent()) {
result.put("success", false); result.put("success", false);
@@ -338,13 +348,13 @@ public class WebhookController {
@PostMapping("/delete") @PostMapping("/delete")
@ResponseBody @ResponseBody
public Map<String, Object> delete(@RequestParam String password) { public Map<String, Object> delete(@RequestParam String password,
Map<String, Object> result = new HashMap<>(); HttpSession session, HttpServletRequest request, HttpServletResponse response) {
if (!verifyPassword(password)) { Map<String, Object> failResult = checkPassword(password, session, request, response);
result.put("success", false); if (failResult != null) {
result.put("message", "비밀번호가 일치하지 않습니다."); return failResult;
return result;
} }
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId()); Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) { if (!webhook.isPresent()) {
result.put("success", false); result.put("success", false);
@@ -360,10 +370,9 @@ public class WebhookController {
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) { private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim(); String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
if (!bindingResult.hasFieldErrors("targetUrl") if (!bindingResult.hasFieldErrors("targetUrl") && !WebhookUrlValidator.isValid(url)) {
&& !url.startsWith("http://") && !url.startsWith("https://")) {
bindingResult.rejectValue("targetUrl", "invalid.url", bindingResult.rejectValue("targetUrl", "invalid.url",
"URL은 http:// 또는 https:// 로 시작해야 합니다."); "올바른 URL 형식이 아닙니다. http:// 또는 https:// 로 시작하는 전체 주소를 입력해주세요.");
} }
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) { if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
bindingResult.rejectValue("eventTypes", "empty.eventTypes", bindingResult.rejectValue("eventTypes", "empty.eventTypes",
@@ -376,6 +385,31 @@ public class WebhookController {
return appServiceFacade.verifyUserPassword(user, password); return appServiceFacade.verifyUserPassword(user, password);
} }
/**
* 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다.
* 연속 실패가 임계치에 도달하면 정책({@code password.confirm.failure.action})에 따라 세션을 강제 종료하거나
* 계정을 차단하고 {@code forceLogout=true} 를 담는다 (무차별 대입 방어).
*/
private Map<String, Object> checkPassword(String password, HttpSession session,
HttpServletRequest request, HttpServletResponse response) {
if (verifyPassword(password)) {
passwordConfirmFailureTracker.reset(session);
return null;
}
PasswordConfirmFailureTracker.Outcome outcome =
passwordConfirmFailureTracker.recordFailure(session, request, response);
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", outcome.getMessage());
if (outcome.isForcedLogout()) {
result.put("forceLogout", true);
result.put("accountLocked", outcome.isAccountLocked());
}
return result;
}
private String currentOrgId() { private String currentOrgId() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null; return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 구독 대상 API ID/명칭 쌍. 화면 표시용(ID → API명 매핑은 {@code ApiService.findApisForApiIds} 사용).
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebhookApiDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String name;
}
@@ -16,11 +16,15 @@ public class WebhookDTO implements Serializable {
private Long id; private Long id;
private String targetUrl; private String targetUrl;
private String secretMasked; private String secretMasked;
private String userSecretMasked;
private String createdDate; private String createdDate;
/** 구독 API ID 목록. */ /** 구독 API ID 목록(폼 prefill 등 내부용). */
private List<String> apiIds = new ArrayList<>(); private List<String> apiIds = new ArrayList<>();
/** 구독 API ID+명칭 목록(화면 표시용). */
private List<WebhookApiDTO> apis = new ArrayList<>();
/** 구독 EventType(코드+한글명) 목록. */ /** 구독 EventType(코드+한글명) 목록. */
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>(); private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
} }
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable; import java.io.Serializable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import javax.validation.constraints.Pattern;
import lombok.Data; import lombok.Data;
import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank; import org.hibernate.validator.constraints.NotBlank;
@@ -27,6 +28,15 @@ public class WebhookRegistrationDTO implements Serializable {
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.") @Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
private String targetUrl; private String targetUrl;
/**
* 사용자 지정 Secret(선택). Webhook 발송 시 HTTP 요청 헤더 값으로 그대로 echo 되므로
* 출력 가능 ASCII(0x20~0x7E)만 허용한다 — 헤더는 non-ASCII/개행을 담을 수 없다.
* 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조).
*/
@Length(max = 500, message = "값은 500자를 초과할 수 없습니다.")
@Pattern(regexp = "^[\\x20-\\x7E]*$", message = "영문·숫자·특수문자 등 ASCII 문자만 입력 가능합니다(한글 등 유니코드 문자는 사용할 수 없습니다).")
private String userSecret;
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */ /** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
private List<String> eventTypes = new ArrayList<>(); private List<String> eventTypes = new ArrayList<>();
@@ -13,6 +13,7 @@ import org.mapstruct.Mapping;
public interface WebhookMapper { public interface WebhookMapper {
@Mapping(target = "secretMasked", ignore = true) @Mapping(target = "secretMasked", ignore = true)
@Mapping(target = "userSecretMasked", ignore = true)
@Mapping(target = "apiIds", ignore = true) @Mapping(target = "apiIds", ignore = true)
@Mapping(target = "eventTypes", ignore = true) @Mapping(target = "eventTypes", ignore = true)
WebhookDTO toDto(WebhookRequest entity); WebhookDTO toDto(WebhookRequest entity);
@@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable {
@Column(name = "SECRET", length = 500) @Column(name = "SECRET", length = 500)
private String secret; private String secret;
/** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */
@Column(name = "USER_SECRET", length = 500)
private String userSecret;
@Column(name = "CREATED_BY", length = 200) @Column(name = "CREATED_BY", length = 200)
private String createdBy; private String createdBy;
@@ -1,5 +1,8 @@
package com.eactive.apim.portal.djb.webhook.service; package com.eactive.apim.portal.djb.webhook.service;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.djb.webhook.dto.WebhookApiDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult; import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO; import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO; import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
@@ -45,6 +48,7 @@ public class WebhookService {
private final WebhookSecretGenerator secretGenerator; private final WebhookSecretGenerator secretGenerator;
private final WebhookEventTypeProvider eventTypeProvider; private final WebhookEventTypeProvider eventTypeProvider;
private final WebhookMapper webhookMapper; private final WebhookMapper webhookMapper;
private final ApiService apiService;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public boolean existsByOrg(String orgId) { public boolean existsByOrg(String orgId) {
@@ -70,6 +74,7 @@ public class WebhookService {
request.setOrgId(orgId); request.setOrgId(orgId);
request.setTargetUrl(dto.getTargetUrl().trim()); request.setTargetUrl(dto.getTargetUrl().trim());
request.setSecret(secret); request.setSecret(secret);
request.setUserSecret(normalizeUserSecret(dto.getUserSecret()));
WebhookRequest saved = requestRepository.save(request); WebhookRequest saved = requestRepository.save(request);
persistChildren(saved.getId(), dto); persistChildren(saved.getId(), dto);
@@ -79,12 +84,17 @@ public class WebhookService {
/** /**
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입. * URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
* userSecret 은 공란으로 제출되면 기존 값을 유지한다(마스킹 표시라 재입력 없이는 원본을 알 수 없으므로).
*/ */
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) { public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
WebhookRequest request = loadOwned(id, orgId); WebhookRequest request = loadOwned(id, orgId);
validate(dto); validate(dto);
request.setTargetUrl(dto.getTargetUrl().trim()); request.setTargetUrl(dto.getTargetUrl().trim());
String userSecret = normalizeUserSecret(dto.getUserSecret());
if (userSecret != null) {
request.setUserSecret(userSecret);
}
requestRepository.save(request); requestRepository.save(request);
apiRepository.deleteByWebhookReqId(id); apiRepository.deleteByWebhookReqId(id);
@@ -128,6 +138,14 @@ public class WebhookService {
return loadOwned(id, orgId).getSecret(); return loadOwned(id, orgId).getSecret();
} }
/**
* 평문 사용자 지정 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
*/
@Transactional(readOnly = true)
public String getPlainUserSecret(Long id, String orgId) {
return loadOwned(id, orgId).getUserSecret();
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
private WebhookRequest loadOwned(Long id, String orgId) { private WebhookRequest loadOwned(Long id, String orgId) {
@@ -152,9 +170,8 @@ public class WebhookService {
} }
private void validate(WebhookRegistrationDTO dto) { private void validate(WebhookRegistrationDTO dto) {
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim(); if (!WebhookUrlValidator.isValid(dto.getTargetUrl())) {
if (!url.startsWith("http://") && !url.startsWith("https://")) { throw new IllegalArgumentException("올바른 URL 형식이 아닙니다. http:// 또는 https:// 로 시작하는 전체 주소를 입력해주세요.");
throw new IllegalArgumentException("URL은 http:// 또는 https:// 로 시작해야 합니다.");
} }
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) { if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요."); throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요.");
@@ -164,6 +181,14 @@ public class WebhookService {
} }
} }
private String normalizeUserSecret(String raw) {
if (raw == null) {
return null;
}
String trimmed = raw.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private List<String> dedup(List<String> values) { private List<String> dedup(List<String> values) {
if (values == null) { if (values == null) {
return java.util.Collections.emptyList(); return java.util.Collections.emptyList();
@@ -174,9 +199,18 @@ public class WebhookService {
private WebhookDTO toDetailDto(WebhookRequest request) { private WebhookDTO toDetailDto(WebhookRequest request) {
WebhookDTO dto = webhookMapper.toDto(request); WebhookDTO dto = webhookMapper.toDto(request);
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK); dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
dto.setUserSecretMasked(request.getUserSecret() == null || request.getUserSecret().isEmpty()
? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream() List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId) .map(WebhookRequestApi::getApiId)
.collect(Collectors.toList());
dto.setApiIds(apiIds);
Map<String, String> apiNames = apiService.findApisForApiIds(apiIds).stream()
.collect(Collectors.toMap(ApiSpecInfoDto::getApiId, ApiSpecInfoDto::getApiName, (a, b) -> a));
dto.setApis(apiIds.stream()
.map(id -> new WebhookApiDTO(id, apiNames.getOrDefault(id, id)))
.collect(Collectors.toList())); .collect(Collectors.toList()));
Map<String, String> names = eventTypeProvider.asMap(); Map<String, String> names = eventTypeProvider.asMap();
@@ -0,0 +1,30 @@
package com.eactive.apim.portal.djb.webhook.service;
import java.net.URI;
import java.net.URISyntaxException;
/**
* Webhook 수신 URL 형식 검증. 컨트롤러(필드 에러 표시)와 서비스(방어적 재검증) 양쪽에서 공용으로 쓴다.
*
* http/https 스킴 + host 존재까지 확인한다("http://" 만 있고 host 가 없는 값 등을 차단).
* 순수 문법 검증만 하며, 실제 도달 가능성(DNS/네트워크)은 검사하지 않는다.
*/
public final class WebhookUrlValidator {
private WebhookUrlValidator() {
}
public static boolean isValid(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
try {
URI uri = new URI(url.trim());
String scheme = uri.getScheme();
return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
&& uri.getHost() != null && !uri.getHost().isEmpty();
} catch (URISyntaxException e) {
return false;
}
}
}
+6
View File
@@ -2,6 +2,8 @@ spring:
config: config:
activate: activate:
on-profile: dev on-profile: dev
# playwright 프로필 합류는 base application.yml 의 spring.profiles.group 으로 처리한다.
# (spring.profiles.include 는 profile-specific 문서에서 금지 - InvalidConfigDataPropertyException)
jpa: jpa:
properties: properties:
hibernate: hibernate:
@@ -38,6 +40,10 @@ gateway:
portal: portal:
# auth-virtual-code: 654321 # auth-virtual-code: 654321
security:
# 개발 서버는 HTTPS 가 아니라 브라우저 Web Crypto 를 쓸 수 없다.
password-encrypt:
enabled: false
dev: dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영 # application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true hot-reload-pages: true
+8
View File
@@ -37,3 +37,11 @@ server:
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다 # 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함). # (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework forward-headers-strategy: framework
portal:
security:
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
# strict 는 스테이지 검증 후 별도 판단(켜면 JS 비활성 사용자는 로그인 불가).
password-encrypt:
enabled: true
strict: false
+4
View File
@@ -22,6 +22,10 @@ spring:
portal: portal:
# 검증 단계에선 사용하지 않음 # 검증 단계에선 사용하지 않음
# auth-virtual-code: 654321 # auth-virtual-code: 654321
security:
# HTTPS 구간이라 전송암호화를 켠다. strict 는 운영 승격 전 검증 후 판단.
password-encrypt:
enabled: true
dev: dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영 # application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true hot-reload-pages: true
+25
View File
@@ -41,6 +41,14 @@ spring:
import: import:
- classpath:menu.yml - classpath:menu.yml
- classpath:roles.yml - classpath:roles.yml
profiles:
# dev/local_rinjaemac 단독 기동 시에도 playwright 전용 빈(test-cleanup 내부 API 등)이 뜨도록
# group 으로 자동 합류. (주의: spring.profiles.include 는 profile-specific 문서에서 금지 -
# application-{profile}.yml 의 on-profile 게이트 안에 두면 InvalidConfigDataPropertyException.
# 반드시 이 base 문서에 정의)
group:
dev: playwright
local_rinjaemac: playwright
data: data:
web: web:
pageable: pageable:
@@ -85,6 +93,10 @@ app:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함. # prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching: resource-caching:
enabled: false enabled: false
# 정적자원 서빙 루트. 기본은 classpath(빌드 산출물).
# local_rinjaemac 프로파일은 file: 로 소스 트리를 직접 바라보도록 오버라이드한다.
web-resources:
static-base: 'classpath:/static/'
security: security:
basic: basic:
@@ -141,6 +153,19 @@ portal:
user-approval: true user-approval: true
password-expiration-days: 90 password-expiration-days: 90
security:
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
# 로컬/개발 서버는 HTTP(비 secure context)라 브라우저 crypto.subtle 을 못 쓰므로 기본은 꺼둔다.
# 켜져 있어도 브라우저가 지원하지 못하면 화면이 평문으로 폴백한다.
password-encrypt:
enabled: false
# REQUEST(폼당 1회용) | SESSION(세션당) | SERVER(서버 고정 + TTL 로테이션)
key-scope: REQUEST
key-ttl-seconds: 300
# true 면 평문 비밀번호 파라미터를 거부한다. JS 비활성 사용자가 로그인하지 못하므로 안정화 후에만 켠다.
strict: false
rsa-key-size: 2048
pages: pages:
- path-pattern: /dashboard/daily_usage - path-pattern: /dashboard/daily_usage
method: GET method: GET
@@ -1,4 +1,4 @@
title.html=DJBank Partner Portal title.html={brand.name} API Portal
password.not.match=Password is not matched password.not.match=Password is not matched
authnumber.not.match=Auth number is not matched. authnumber.not.match=Auth number is not matched.
apim.route.register.file=File apim.route.register.file=File
@@ -1,4 +1,4 @@
title.html=DJBank \uD30C\uD2B8\uB108\uD3EC\uD0C8 title.html={brand.name} API \uD3EC\uD0C8
apim.route.register.file=\uCCA8\uBD80\uD30C\uC77C apim.route.register.file=\uCCA8\uBD80\uD30C\uC77C
field.required=\uD544\uC218 \uC785\uB825\uD56D\uBAA9\uC785\uB2C8\uB2E4. field.required=\uD544\uC218 \uC785\uB825\uD56D\uBAA9\uC785\uB2C8\uB2E4.
companyName=\uD68C\uC0AC\uBA85 companyName=\uD68C\uC0AC\uBA85
+637 -93
View File
@@ -23,6 +23,13 @@ body {
color: #1A1A2E; color: #1A1A2E;
background-color: #FFFFFF; background-color: #FFFFFF;
overflow-x: hidden; overflow-x: hidden;
min-height: 100vh;
display: flex;
flex-direction: column;
}
body > * {
flex-shrink: 0;
width: 100%;
} }
ul, ol { ul, ol {
@@ -824,16 +831,20 @@ hr {
.env-badge { .env-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
margin-left: 10px; position: fixed;
padding: 2px 8px; top: 0;
border-radius: 4px; left: 0;
padding: 1px 6px;
border-radius: 0 0 4px 0;
background: var(--accent-orange); background: var(--accent-orange);
color: var(--white); color: var(--white);
font-size: 11px; font-size: 9px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-transform: uppercase; text-transform: uppercase;
line-height: 1.6; line-height: 1.5;
z-index: 1100;
pointer-events: none;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.env-badge { .env-badge {
@@ -897,7 +908,7 @@ hr {
} }
.logo img { .logo img {
height: 32px; height: 32px;
width: 114px; width: auto;
display: block; display: block;
} }
@@ -1266,6 +1277,9 @@ hr {
margin: 0; margin: 0;
text-align: left; text-align: left;
} }
.mobile-drawer .drawer-welcome.authenticated .last-login-info {
padding: 0;
}
.mobile-drawer .drawer-footer { .mobile-drawer .drawer-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -1705,10 +1719,22 @@ hr {
transform: translateY(0); transform: translateY(0);
} }
.last-login-info {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 9pt;
color: var(--text-gray);
white-space: nowrap;
padding: 0 6px;
line-height: 1.4;
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.header-user-info { .header-user-info {
display: none; display: none;
} }
.auth-group .last-login-info {
display: none;
}
} }
/* 원래 있던 Footer 스타일 (주석 처리) /* 원래 있던 Footer 스타일 (주석 처리)
.global-footer { .global-footer {
@@ -1866,6 +1892,7 @@ hr {
background-color: rgb(15, 23, 42); background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139); color: rgb(100, 116, 139);
padding: 60px 0px; padding: 60px 0px;
margin-top: auto;
} }
.global-footer .container { .global-footer .container {
max-width: 1200px; max-width: 1200px;
@@ -5454,6 +5481,8 @@ select.form-control {
} }
.auth-group.authenticated { .auth-group.authenticated {
gap: 0; gap: 0;
flex-direction: column;
align-items: flex-end;
} }
.auth-group .login-btn { .auth-group .login-btn {
font-size: 15px; font-size: 15px;
@@ -5757,6 +5786,7 @@ select.form-control {
position: fixed; position: fixed;
top: 16px; top: 16px;
right: 20px; right: 20px;
width: max-content;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -10949,11 +10979,54 @@ body.index-page-body {
color: #6b7280; color: #6b7280;
} }
/* 하단 중앙 "API 사용 신청" 버튼 */ /* API 사용 신청은 클라이언트 신청 흐름에서 진행됨을 안내 */
.api-apply-footer { .api-apply-footer {
display: flex; display: flex;
justify-content: center; align-items: center;
margin-top: 32px; gap: 28px;
width: 100%;
margin-top: 48px;
padding: 32px 44px;
border: 1px solid #d5e6f7;
border-radius: 16px;
background: linear-gradient(100deg, #eaf4fd, #dfedfb);
}
.api-apply-footer__body {
flex: 1;
min-width: 0;
}
.api-apply-footer__title {
margin: 0;
color: #0d3264;
font-size: 22px;
font-weight: 700;
}
.api-apply-footer__guide {
margin: 6px 0 0;
color: #64748B;
font-size: 15px;
}
.api-apply-footer__action {
flex: none;
margin-left: auto;
padding: 15px 30px;
font-size: 15px;
white-space: nowrap;
}
@media (max-width: 768px) {
.api-apply-footer {
flex-direction: column;
align-items: flex-start;
padding: 24px;
}
.api-apply-footer__title {
font-size: 20px;
}
.api-apply-footer__action {
justify-content: center;
width: 100%;
margin-left: 0;
}
} }
.api-overview-card { .api-overview-card {
@@ -21839,6 +21912,35 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #334155; color: #334155;
word-break: break-word; word-break: break-word;
} }
.recent-apps .recent-apps-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.recent-apps .recent-apps-delete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
background: #ffffff;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #dc2626;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.recent-apps .recent-apps-delete:hover {
background: #fef2f2;
border-color: #f87171;
}
.recent-apps .recent-apps-delete:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
.page-title { .page-title {
font-size: 40px; font-size: 40px;
@@ -23345,81 +23447,302 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
.service-intro { .service-intro {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #fff; background-color: #fff;
color: #000; color: #1e2939;
display: flex;
flex-direction: column;
gap: 20px;
}
.service-intro__title {
margin: 0;
font-size: 25px;
line-height: 1.4;
letter-spacing: -0.01em;
}
.service-intro__lead {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.5;
letter-spacing: -0.01em;
}
.service-intro__desc {
margin: 0;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__spacer {
height: 10px;
}
.service-intro__section-title {
margin: 0;
font-size: 20px;
line-height: 1.5;
}
.service-intro__section-body {
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__section-body p {
margin: 0;
}
.service-intro__section-body p + p {
margin-top: 8px;
}
.service-intro__list {
margin: 0;
padding-left: 22px;
list-style: disc;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__list li + li {
margin-top: 4px;
} }
@media (max-width: 768px) { .intro-callout {
.service-intro { background: #0B2A5B;
padding: 16px; border-radius: 16px;
gap: 16px; padding: 34px 40px;
display: flex;
gap: 30px;
align-items: center;
box-shadow: 0 14px 34px rgba(11, 42, 91, 0.18);
}
.intro-callout__icon {
width: 56px;
height: 56px;
flex: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.14);
display: grid;
place-items: center;
}
.intro-callout ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 11px;
}
.intro-callout li {
position: relative;
padding-left: 15px;
color: #C2D4EA;
font-size: 15.5px;
line-height: 1.6;
}
.intro-callout li::before {
content: "";
position: absolute;
left: 0;
top: 10px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #00acdd;
}
.intro-callout li strong {
color: #fff;
font-weight: 700;
}
.intro-section__eyebrow {
display: block;
font-size: 13px;
font-weight: 800;
letter-spacing: 2.4px;
color: #0049b4;
margin-bottom: 9px;
}
.intro-section__title {
font-size: 30px;
font-weight: 900;
letter-spacing: -1px;
color: #0B2A5B;
margin: 0;
}
.intro-section__lead {
margin-top: 14px;
font-size: 16px;
color: #4a5565;
line-height: 1.85;
max-width: 830px;
}
.intro-section + .intro-section {
margin-top: 76px;
}
.intro-who {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 28px;
}
.intro-who__item {
border: 1px solid #f3f4f6;
border-radius: 14px;
padding: 22px 20px;
text-align: center;
background: #fff;
}
.intro-who__item p {
margin-top: 6px;
font-size: 13.6px;
color: #4a5565;
line-height: 1.65;
}
.intro-who__title {
margin-top: 12px;
font-size: 15.5px;
font-weight: 800;
color: #0B2A5B;
}
.intro-grid3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 28px;
}
.intro-card {
border: 1px solid #f3f4f6;
border-radius: 16px;
padding: 26px;
background: #fff;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.intro-card__icon {
width: 44px;
height: 44px;
border-radius: 11px;
background: #EFF6FD;
display: grid;
place-items: center;
margin-bottom: 16px;
}
.intro-card h3 {
font-size: 18px;
font-weight: 800;
color: #0B2A5B;
letter-spacing: -0.5px;
margin: 0;
}
.intro-card p {
margin-top: 9px;
font-size: 14.6px;
color: #4a5565;
line-height: 1.75;
}
.intro-card code {
font-family: "Fira Code", monospace;
font-size: 13px;
background: #EFF4FA;
color: #0049b4;
padding: 2px 6px;
border-radius: 6px;
}
.intro-card__tag {
display: inline-block;
margin-top: 14px;
font-size: 12.5px;
font-weight: 700;
color: #0049b4;
background: #EEF6FD;
border-radius: 6px;
padding: 5px 10px;
}
.intro-diagram {
margin-top: 28px;
border: 1px solid #f3f4f6;
border-radius: 16px;
padding: 30px 24px;
background: #FAFCFF;
overflow-x: auto;
}
.intro-diagram svg {
display: block;
width: 100%;
min-width: 700px;
height: auto;
}
.intro-steps {
margin-top: 34px;
position: relative;
padding-left: 38px;
}
.intro-steps::before {
content: "";
position: absolute;
left: 5px;
top: 14px;
bottom: 14px;
width: 2px;
background: #D8E5F3;
}
.intro-step {
display: flex;
gap: 22px;
margin-bottom: 18px;
position: relative;
}
.intro-step::before {
content: "";
position: absolute;
left: -38px;
top: 34px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #0049b4;
border: 3px solid #fff;
box-shadow: 0 0 0 3px #D9E7F7;
}
.intro-step__icon {
width: 88px;
height: 82px;
flex: none;
border: 1px solid #f3f4f6;
border-radius: 14px;
display: grid;
place-items: center;
background: #fff;
}
.intro-step__body {
flex: 1;
border: 1px solid #f3f4f6;
border-radius: 14px;
padding: 19px 26px;
background: #fff;
}
.intro-step__body p {
margin-top: 5px;
font-size: 14.4px;
color: #4a5565;
}
.intro-step__title {
font-size: 17px;
font-weight: 800;
color: #0B2A5B;
}
.intro-step__title em {
font-style: normal;
color: #0049b4;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.6px;
margin-right: 10px;
}
.intro-cta {
margin-top: 64px;
border-radius: 18px;
padding: 38px 44px;
background: linear-gradient(100deg, #EAF4FD, #DFEDFB);
display: flex;
align-items: center;
gap: 28px;
border: 1px solid #D5E6F7;
}
.intro-cta__body h3 {
margin: 0;
font-size: 22px;
color: #0B2A5B;
}
.intro-cta__body p {
margin-top: 6px;
font-size: 15px;
color: #4a5565;
}
.intro-cta .btn-action-primary {
margin-left: auto;
flex: none;
font-size: 15.5px;
padding: 15px 30px;
}
@media (max-width: 1024px) {
.intro-who,
.intro-grid3 {
grid-template-columns: repeat(2, 1fr);
} }
.service-intro__title { .intro-cta {
font-size: 22px; flex-direction: column;
align-items: flex-start;
} }
.service-intro__lead { .intro-cta .btn-action-primary {
font-size: 17px; margin-left: 0;
} }
.service-intro__section-title { }
font-size: 17px; @media (max-width: 640px) {
.intro-who,
.intro-grid3 {
grid-template-columns: 1fr;
} }
.service-intro__desc, .service-intro__section-body, .service-intro__list { .intro-callout {
font-size: 14px; flex-direction: column;
align-items: flex-start;
}
.intro-section__title {
font-size: 24px;
}
.intro-step {
flex-direction: column;
} }
} }
.service-main { .service-main {
@@ -24794,6 +25117,9 @@ input[type=checkbox]:checked + .custom-checkbox {
.oauth2-2legged__hero-body { .oauth2-2legged__hero-body {
align-items: center; align-items: center;
} }
.oauth2-2legged__hero-illust {
order: -1;
}
.oauth2-2legged__prereq-grid { .oauth2-2legged__prereq-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -25471,29 +25797,31 @@ input[type=checkbox]:checked + .custom-checkbox {
padding: 24px 16px 60px; padding: 24px 16px 60px;
font-family: "Spoqa Han Sans Neo", "Noto Sans CJK KR", sans-serif; font-family: "Spoqa Han Sans Neo", "Noto Sans CJK KR", sans-serif;
} }
.webhook-container .field-help {
.step1-wrap .field-help {
color: #64748b; color: #64748b;
font-size: 13px; font-size: 13px;
margin-top: 6px; margin-top: 6px;
} }
.webhook-container .field-help-red { .step1-wrap .field-help-red {
color: #f4253c; color: #f4253c;
font-size: 13px; font-size: 13px;
margin-top: 6px; margin-top: 6px;
font-weight: 500; font-weight: 500;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.webhook-container .field-help-red { .step1-wrap .field-help-red {
font-size: 11px; font-size: 11px;
} }
} }
.webhook-container .field-error, .step1-wrap .field-error,
.webhook-container .webhook-field-error { .step1-wrap .webhook-field-error {
color: #f4253c; color: #f4253c;
font-size: 13px; font-size: 13px;
font-weight: 600;
margin-top: 6px; margin-top: 6px;
} }
.webhook-container .req { .step1-wrap .req {
color: #f4253c; color: #f4253c;
} }
@@ -26148,7 +26476,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
.step1-wrap .s1-form-card .webhook-info-group .secret-action-row { .step1-wrap .s1-form-card .webhook-info-group .secret-action-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
} }
@@ -26159,24 +26487,24 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
.step1-wrap .s1-form-card .webhook-info-group .secret-box { .step1-wrap .s1-form-card .webhook-info-group .secret-box {
flex: 1; flex: 1;
max-width: 250px; min-width: 0;
height: 48px; min-height: 48px;
background-color: #efefef; background-color: #efefef;
border-radius: 10px; border-radius: 10px;
padding: 0 20px; padding: 12px 20px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; font-size: 13px;
font-size: 14px; line-height: 1.4;
color: #4e5968; color: #4e5968;
font-weight: 500; font-weight: 500;
box-sizing: border-box; box-sizing: border-box;
letter-spacing: 1px; word-break: break-all;
white-space: normal;
border: 1px solid #DFDFDF; border: 1px solid #DFDFDF;
} }
@media (max-width: 576px) { @media (max-width: 576px) {
.step1-wrap .s1-form-card .webhook-info-group .secret-box { .step1-wrap .s1-form-card .webhook-info-group .secret-box {
max-width: 100%;
width: 100%; width: 100%;
flex: none; flex: none;
} }
@@ -26807,7 +27135,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
.api-status .as-alert-card .as-tl-row { .api-status .as-alert-card .as-tl-row {
display: grid; display: grid;
grid-template-columns: 60px 76px 1fr; grid-template-columns: 60px 76px 64px 1fr;
gap: 12px; gap: 12px;
font-size: 13px; font-size: 13px;
} }
@@ -26815,6 +27143,37 @@ input[type=checkbox]:checked + .custom-checkbox {
color: var(--as-muted); color: var(--as-muted);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.api-status .as-alert-card .as-tl-row .as-tl-state {
font-size: 11px;
font-weight: 500;
text-align: center;
height: -webkit-fit-content;
height: fit-content;
padding: 2px 8px;
border-radius: var(--as-pill);
background: var(--as-gray-bg);
color: var(--as-text-2);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-INVESTIGATING {
background: var(--as-err-bg);
color: var(--as-err);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-IDENTIFIED {
background: #fbe9d7;
color: #c95a0f;
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-MONITORING {
background: var(--as-warn-bg);
color: var(--as-warn);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-RESOLVED {
background: var(--as-ok-bg);
color: var(--as-ok);
}
.api-status .as-alert-card .as-tl-row .as-tl-state.state-CANCELED {
background: var(--as-gray-bg);
color: var(--as-muted);
}
.api-status .as-alert-card .as-tl-row .as-tl-who { .api-status .as-alert-card .as-tl-row .as-tl-who {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
@@ -27392,7 +27751,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-bottom: 12px; margin-bottom: 12px;
} }
.api-status .as-alert-card .as-tl-row { .api-status .as-alert-card .as-tl-row {
grid-template-columns: 52px 1fr; grid-template-columns: 52px 68px 1fr;
} }
.api-status .as-alert-card .as-tl-who { .api-status .as-alert-card .as-tl-who {
display: none; display: none;
@@ -27464,6 +27823,191 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.nt-page,
.nt-page h1,
.nt-page h2,
.nt-page h3 {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.nt-page {
margin: 44px 0 60px;
}
.nt-page .nt-header {
margin-bottom: 24px;
}
.nt-page .nt-header h1 {
margin: 0 0 8px;
font-size: 32px;
font-weight: 700;
color: #1A1A2E;
}
.nt-page .nt-desc {
margin: 0;
font-size: 14px;
color: #64748B;
line-height: 1.5;
}
.nt-page .nt-hint {
margin: 8px 0 0;
font-size: 12px;
color: #64748B;
}
.nt-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 24px;
align-items: start;
}
@media (max-width: 900px) {
.nt-columns {
grid-template-columns: 1fr;
}
}
.nt-catalog {
display: flex;
flex-direction: column;
gap: 16px;
}
.nt-category-card {
padding: 24px;
}
.nt-category-card .nt-category-title {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
color: #1A1A2E;
}
.nt-target {
position: relative;
padding: 10px 0;
border-bottom: 1px solid #E2E8F0;
}
.nt-target:last-child {
border-bottom: none;
}
.nt-target .nt-risk-badge {
margin-left: 8px;
}
.nt-params {
margin: 12px 0 4px 26px;
padding: 12px 16px;
background: #F8FAFC;
border-radius: 8px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.nt-params .nt-param-field {
margin: 0;
min-width: 180px;
flex: 1 1 200px;
}
.nt-params .nt-param-field label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: #64748B;
}
.nt-side {
position: sticky;
top: 96px;
}
.nt-recipient-card {
padding: 24px;
}
.nt-recipient-card h2 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #1A1A2E;
}
.nt-recipient-card .btn-block {
width: 100%;
margin-top: 8px;
}
.nt-history-card {
margin-top: 24px;
padding: 24px;
}
.nt-history-card h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: #1A1A2E;
}
.nt-table-wrap {
margin-top: 12px;
overflow-x: auto;
}
.nt-table-wrap table {
width: 100%;
border-collapse: collapse;
border: 1px solid #E2E8F0;
}
.nt-table-wrap th, .nt-table-wrap td {
padding: 10px 14px;
border: 1px solid #E2E8F0;
text-align: left;
font-size: 14px;
vertical-align: top;
white-space: nowrap;
}
.nt-table-wrap thead th {
background: #EFF6FF;
font-weight: 600;
color: #1A1A2E;
}
.nt-table-wrap tbody td {
color: #64748B;
}
.nt-message-cell {
min-width: 280px;
white-space: normal;
}
.nt-message-textarea {
width: 100%;
min-height: 64px;
padding: 8px 10px;
border: 1px solid #E2E8F0;
border-radius: 6px;
background: #F8FAFC;
color: #1A1A2E;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 12px;
line-height: 1.5;
resize: vertical;
}
.nt-message-textarea:focus {
outline: none;
border-color: #0049b4;
}
.nt-row-note {
margin-top: 4px;
font-size: 12px;
}
.nt-row-warning {
color: #FF6B6B;
}
.nt-row-error {
color: #FF6B6B;
font-weight: 500;
}
.d-none { .d-none {
display: none !important; display: none !important;
} }

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