12 Commits

Author SHA1 Message Date
Rinjae 63d4d7268c Merge branch 'master' into design
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-07 10:19:52 +09:00
Rinjae c42a278c8e 수신자 이름 지정 인증번호 발송 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- %USER_NAME% 지원 위한 createMessageRecipient 수정
- GUEST_USER_NAME 상수 및 관련 기본값 처리 로직 추가
2026-08-07 09:20:39 +09:00
hong 83352e3669 fix : css 수정 2026-08-06 13:21:41 +09:00
hong 317b8d781d Merge branch 'design' of http://172.30.1.50:3000/djb-eapim/eapim-portal into design 2026-08-06 13:20:27 +09:00
hong 206e72edde fix : 디자인 수정 2026-08-06 13:20:21 +09:00
Rinjae 34dce308dc 메뉴 내부 API 허용 IP 검증 로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- IpAddressMatcher로 CIDR/와일드카드 지원
- 허용 IP 목록 설명 필드 추가
- RemoteAddr 정규화 로직 제거
2026-08-06 09:59:34 +09:00
Rinjae b3fc5c06bf webhook 수정 화면에서 이벤트 코드 출력 제거.
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-06 09:24:48 +09:00
Rinjae eb74a99a5a - PersonalDataEncryptConverter: isFakeMode 메서드 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- LegacyEncryptionMigration: 허용 IP 검증 로직 추가 - 스킵 내역 로그 강화
- 암호화 대상 컬럼 추가 및 DamoMode 구분 지원
2026-08-05 17:47:37 +09:00
Rinjae 38e5a7f13c API 카드 레이아웃 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 1행 4열 고정 및 반응형 flex-wrap 적용
- 카드 간격/정렬 방식 및 max-width 설정 추가
2026-08-05 15:42:48 +09:00
Rinjae 3b7c2e5a8f 푸터 "관련 사이트" 조회 및 렌더링 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- RelatedSite/RelatedSiteService 생성 및 PortalProperty 연계
- GlobalControllerAdvice에서 셀렉트 라벨/목록 주입
- footer.html 템플릿 및 관련 JS 수정
2026-08-05 15:23:59 +09:00
Rinjae 47099ec485 Merge branch 'feats/menu-control'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-05 14:21:22 +09:00
Rinjae 6dbf6af3de 서비스 소개 페이지 텍스트 간소화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 'DJBank API Portal 소개'로 제목 및 내용 정리
2026-08-05 14:19:58 +09:00
18 changed files with 398 additions and 133 deletions
@@ -4,11 +4,23 @@ public interface AuthNumberService {
String sendRequestAuthNumber(String recipientKey, String msgType); String sendRequestAuthNumber(String recipientKey, String msgType);
/**
* 기본 TTL 로 발송하되 수신자 이름을 지정한다. 세 번째 인자가 int 인 오버로드(TTL 지정)와 혼동하지 말 것.
*/
String sendRequestAuthNumber(String recipientKey, String msgType, String username);
/** /**
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과 * 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다. * 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
*/ */
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds); String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
/**
* 수신자 이름을 지정해 인증번호를 발송한다. 메시지 템플릿의 %USER_NAME% 치환에 사용되며,
* 회원가입·아이디/비밀번호 찾기처럼 사용자 이름을 알 수 없는 흐름은 "guest" 를 넘긴다.
* username 이 비어 있으면 %USER_NAME% 은 치환되지 않고 원문이 그대로 남는다.
*/
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username);
boolean verifyAuthNumber(String recipientKey, String authNumber); boolean verifyAuthNumber(String recipientKey, String authNumber);
} }
@@ -45,19 +45,31 @@ public class AuthNumberServiceImpl implements AuthNumberService {
@Override @Override
@Transactional(noRollbackFor = AuthNumberException.class) @Transactional(noRollbackFor = AuthNumberException.class)
public String sendRequestAuthNumber(String recipientKey, String msgType) { public String sendRequestAuthNumber(String recipientKey, String msgType) {
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime); return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, null);
}
@Override
@Transactional(noRollbackFor = AuthNumberException.class)
public String sendRequestAuthNumber(String recipientKey, String msgType, String username) {
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, username);
} }
@Override @Override
@Transactional(noRollbackFor = AuthNumberException.class) @Transactional(noRollbackFor = AuthNumberException.class)
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) { public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
return sendRequestAuthNumber(recipientKey, msgType, ttlSeconds, null);
}
@Override
@Transactional(noRollbackFor = AuthNumberException.class)
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username) {
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds); logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
validateResendTime(recipientKey); validateResendTime(recipientKey);
String authNumber = generator.generateAuthNumber(); String authNumber = generator.generateAuthNumber();
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType); MessageRecipient recipient = createMessageRecipient(recipientKey, msgType, username);
messageSender.sendAuthMessage(recipient, authNumber, msgType); messageSender.sendAuthMessage(recipient, authNumber, msgType);
storage.saveAuthNumber(recipientKey, authNumber, storage.saveAuthNumber(recipientKey, authNumber,
@@ -99,9 +111,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
}); });
} }
private MessageRecipient createMessageRecipient(String recipientKey, String msgType) { private MessageRecipient createMessageRecipient(String recipientKey, String msgType, String username) {
MessageRecipient recipient = new MessageRecipient(); MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(recipientKey); recipient.setUserId(recipientKey);
// 메시지 템플릿 %USER_NAME% 치환용. 비어 있으면 MessageSendService 가 파라미터 자체를 넣지 않는다.
if (username != null && !username.trim().isEmpty()) {
recipient.setUsername(username);
}
if ("SMS".equalsIgnoreCase(msgType)) { if ("SMS".equalsIgnoreCase(msgType)) {
recipient.setPhone(recipientKey); recipient.setPhone(recipientKey);
} else if ("EMAIL".equalsIgnoreCase(msgType)) { } else if ("EMAIL".equalsIgnoreCase(msgType)) {
@@ -17,6 +17,12 @@ public class AuthFacadeImpl implements AuthFacade {
private final AuthNoticeProperties authNoticeProperties; private final AuthNoticeProperties authNoticeProperties;
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class); private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
/**
* 회원가입·아이디/비밀번호 찾기 등 로그인 이전 흐름은 수신자 이름을 알 수 없으므로
* 메시지 템플릿 %USER_NAME% 자리에 넣을 기본값.
*/
private static final String GUEST_USER_NAME = "guest";
/** /**
* 인증 요청 * 인증 요청
@@ -43,7 +49,7 @@ public class AuthFacadeImpl implements AuthFacade {
} }
try { try {
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType); String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType, GUEST_USER_NAME);
response.setValid(true); response.setValid(true);
response.setMessage("인증번호를 발송하였습니다."); response.setMessage("인증번호를 발송하였습니다.");
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출 // 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
@@ -12,6 +12,8 @@ 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.djb.footer.RelatedSite;
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@ControllerAdvice @ControllerAdvice
@@ -35,6 +37,9 @@ public class GlobalControllerAdvice {
@Autowired @Autowired
private AuthNoticeProperties authNoticeProperties; private AuthNoticeProperties authNoticeProperties;
@Autowired
private RelatedSiteService relatedSiteService;
@Autowired @Autowired
private Environment environment; private Environment environment;
@@ -111,4 +116,21 @@ public class GlobalControllerAdvice {
return portalPropertyService.getOrCreateProperty( return portalPropertyService.getOrCreateProperty(
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처"); "Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
} }
/**
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
*/
@ModelAttribute("relatedSitesLabel")
public String relatedSitesLabel() {
return relatedSiteService.getLabel();
}
/**
* 푸터 관련 사이트 목록. PortalProperty(Portal/footer.related-sites)의 '이름=URL' 줄 목록을 파싱한 결과.
* 비어 있으면 푸터에서 셀렉트 자체를 렌더하지 않는다.
*/
@ModelAttribute("relatedSites")
public List<RelatedSite> relatedSites() {
return relatedSiteService.getSites();
}
} }
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.common.migration;
import com.eactive.apim.portal.common.util.StringMaskingUtil; import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter; import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -20,6 +21,8 @@ import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** /**
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구. * [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
@@ -29,15 +32,19 @@ import java.util.Map;
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는 * 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p> * 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
* *
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며, * <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p> * 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
* 작업 완료 후 이 클래스는 제거한다.</p>
* *
* <pre> * <pre>
* # 미리보기(변경 안 함) * # 미리보기(변경 안 함)
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy' * curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
* # 실제 실행 (PII 컬럼) * # 실제 실행 (PII 컬럼)
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false' * curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함 * # audit 컬럼(created_by/last_modified_by, 19개 테이블)까지 포함
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true' * curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
* </pre> * </pre>
*/ */
@@ -46,13 +53,14 @@ import java.util.Map;
@RequestMapping("/internal/migration") @RequestMapping("/internal/migration")
public class LegacyEncryptionMigrationController { public class LegacyEncryptionMigrationController {
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */ /** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
private static final List<TargetTable> PII_TARGETS = Arrays.asList( private static final List<TargetTable> PII_TARGETS = Arrays.asList(
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")), new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")), new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")), new TargetTable("tseairm02", Arrays.asList("cphnno", "emad", "ofctelno")),
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")), new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")) new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")),
new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE"))
); );
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */ /** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
@@ -61,16 +69,24 @@ public class LegacyEncryptionMigrationController {
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API", "DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms", "ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
"ptl_user_privacy_policy_agreement", "ptl_approval_line", "ptl_user_privacy_policy_agreement", "ptl_approval_line",
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application" "PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application",
"PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY"
); );
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by"); private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
static final String PROP_GROUP = "Portal";
static final String PROP_ALLOW_IPS = "migration.internal.allow-ips";
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final PortalPropertyService portalPropertyService;
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter(); private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) { public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
PortalPropertyService portalPropertyService) {
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다. // EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
this.jdbcTemplate = new JdbcTemplate(emsDataSource); this.jdbcTemplate = new JdbcTemplate(emsDataSource);
this.portalPropertyService = portalPropertyService;
} }
@PostMapping("/encrypt-legacy") @PostMapping("/encrypt-legacy")
@@ -78,7 +94,7 @@ public class LegacyEncryptionMigrationController {
public Map<String, Object> encryptLegacy(HttpServletRequest request, public Map<String, Object> encryptLegacy(HttpServletRequest request,
@RequestParam(defaultValue = "true") boolean dryRun, @RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "false") boolean includeAudit) { @RequestParam(defaultValue = "false") boolean includeAudit) {
assertLocalOnly(request); assertAllowedIp(request);
assertNotBypass(); assertNotBypass();
List<TargetTable> targets = new ArrayList<>(PII_TARGETS); List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
@@ -90,24 +106,36 @@ public class LegacyEncryptionMigrationController {
List<Map<String, Object>> results = new ArrayList<>(); List<Map<String, Object>> results = new ArrayList<>();
int totalChanged = 0; int totalChanged = 0;
int totalSkipped = 0;
for (TargetTable target : targets) { for (TargetTable target : targets) {
for (String column : target.columns) { for (String column : target.columns) {
Map<String, Object> r = processColumn(target.table, column, dryRun); Map<String, Object> r = processColumn(target.table, column, dryRun);
results.add(r); results.add(r);
totalChanged += (int) r.get("changed"); totalChanged += (int) r.get("changed");
totalSkipped += (int) r.get("skipped");
} }
} }
Map<String, Object> response = new LinkedHashMap<>(); Map<String, Object> response = new LinkedHashMap<>();
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed"); response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
response.put("damoMode", resolveDamoMode());
response.put("includeAudit", includeAudit); response.put("includeAudit", includeAudit);
response.put("totalChanged", totalChanged); response.put("totalChanged", totalChanged);
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
response.put("totalSkipped", totalSkipped);
response.put("results", results); response.put("results", results);
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}", log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
dryRun ? "dry-run" : "executed", includeAudit, totalChanged); dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
return response; return response;
} }
private String resolveDamoMode() {
if (converter.isBypassMode()) {
return "BYPASS";
}
return converter.isFakeMode() ? "FAKE" : "REAL";
}
/** /**
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE. * 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
*/ */
@@ -125,11 +153,13 @@ public class LegacyEncryptionMigrationController {
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString()); log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
r.put("distinct", 0); r.put("distinct", 0);
r.put("changed", 0); r.put("changed", 0);
r.put("skipped", 0);
r.put("error", e.getMessage()); r.put("error", e.getMessage());
return r; return r;
} }
int changed = 0; int changed = 0;
int skipped = 0;
for (String value : values) { for (String value : values) {
String normalized; String normalized;
try { try {
@@ -139,6 +169,13 @@ public class LegacyEncryptionMigrationController {
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString()); log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
continue; continue;
} }
if ((normalized == null || normalized.isEmpty()) && !value.isEmpty()) {
// 방어: 원본이 비어있지 않은데 정규화 결과가 빈 값 → 절대 UPDATE 하지 않음 (데이터 소실 방지)
skipped++;
log.warn("[마이그레이션] 정규화 결과가 빈 값 — UPDATE 생략 table={} column={} valueLen={}",
table, column, value.length());
continue;
}
if (normalized != null && !normalized.equals(value)) { if (normalized != null && !normalized.equals(value)) {
if (!dryRun) { if (!dryRun) {
jdbcTemplate.update( jdbcTemplate.update(
@@ -151,6 +188,7 @@ public class LegacyEncryptionMigrationController {
r.put("distinct", values.size()); r.put("distinct", values.size());
r.put("changed", changed); r.put("changed", changed);
r.put("skipped", skipped);
return r; return r;
} }
@@ -169,21 +207,45 @@ public class LegacyEncryptionMigrationController {
} }
/** /**
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다. * PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사.
* 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델)
* 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에
* 호출자 IP 를 추가하고 완료 후 원복한다.
*/ */
private void assertLocalOnly(HttpServletRequest request) { private void assertAllowedIp(HttpServletRequest request) {
String remote = request.getRemoteAddr(); String remote = canonicalize(request.getRemoteAddr());
boolean localAddr = "127.0.0.1".equals(remote)
|| "0:0:0:0:0:0:0:1".equals(remote)
|| "::1".equals(remote);
boolean viaProxy = request.getHeader("X-Forwarded-For") != null; boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
if (!localAddr || viaProxy) {
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For"))); .map(String::trim)
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다."); .filter(ip -> !ip.isEmpty())
.map(LegacyEncryptionMigrationController::canonicalize)
.collect(Collectors.toSet());
if (viaProxy || !allowed.contains(remote)) {
log.warn("[마이그레이션] 비허용 접근 차단 remoteAddr={} viaProxy={} xff={}",
StringMaskingUtil.maskIpAddress(remote), viaProxy,
StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"허용되지 않은 접근입니다. (PTL_PROPERTY " + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
} }
} }
private String resolveAllowIps() {
try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
DEFAULT_ALLOW_IPS, "레거시 암호화 마이그레이션 내부 API 허용 IP 목록(콤마 구분)");
} catch (Exception e) {
log.warn("[마이그레이션] 허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
return DEFAULT_ALLOW_IPS;
}
}
/** IPv6 loopback 표기 통일 */
private static String canonicalize(String ip) {
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
}
private static final class TargetTable { private static final class TargetTable {
final String table; final String table;
final List<String> columns; final List<String> columns;
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.djb.footer;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
/**
* 푸터 "관련 사이트" 셀렉트에 노출되는 사이트 한 건.
*
* <p>{@link RelatedSiteService} 가 PortalProperty 문자열을 파싱해 만든다.</p>
*/
@Getter
@ToString
@AllArgsConstructor
public class RelatedSite {
/** 셀렉트에 표시되는 이름 (예: 신한은행) */
private final String name;
/** 이동 대상 URL (http/https 절대주소 또는 `/` 로 시작하는 사이트 상대주소) */
private final String url;
}
@@ -0,0 +1,131 @@
package com.eactive.apim.portal.djb.footer;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 푸터 "관련 사이트" 셀렉트의 라벨과 목록을 DB(PortalProperty)에서 조회한다.
*
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
* 의 자동 생성(self-seed)이 동작하도록 한다.</p>
*
* <ul>
* <li>{@code footer.related-sites.label} - 셀렉트 첫 항목(플레이스홀더) 문구</li>
* <li>{@code footer.related-sites} - 사이트 목록. 한 줄에 하나씩 {@code 이름=URL}</li>
* </ul>
*
* <h3>목록 표기 규칙</h3>
* <pre>
* # 주석 (# 으로 시작하는 줄은 무시)
* 신한은행=http://www.shinhan.com
* 제주은행 = https://www.jejubank.co.kr ← = 앞뒤 공백 허용
* </pre>
* <ul>
* <li>빈 줄 · {@code #} 로 시작하는 줄은 건너뛴다.</li>
* <li>줄 순서가 곧 화면 노출 순서다.</li>
* <li>URL 에 {@code =} 가 들어가도 <b>첫 번째</b> {@code =} 만 구분자로 쓰므로 안전하다.</li>
* <li>{@code =} 가 없거나 이름/URL 이 비었거나 허용되지 않은 스킴이면 그 줄만 버리고 WARN 로그를
* 남긴다. (한 줄이 잘못돼도 나머지 사이트는 정상 노출)</li>
* </ul>
*
* <p>허용 스킴을 {@code http/https} 와 사이트 상대경로로 제한하는 이유는, 이 값이 관리자 화면에서
* 편집되어 그대로 앵커/스크립트 이동 대상이 되기 때문이다({@code javascript:} 등 차단).</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RelatedSiteService {
private static final String GROUP = "Portal";
static final String NAME_LABEL = "footer.related-sites.label";
static final String NAME_SITES = "footer.related-sites";
static final String DESC_LABEL = "푸터 관련 사이트 셀렉트 라벨(첫 항목 문구)";
static final String DESC_SITES = "푸터 관련 사이트 목록. 한 줄에 하나씩 '이름=URL' (빈 줄·# 주석 무시)";
static final String DEFAULT_LABEL = "DJBank 관련 사이트";
/** 기본값: 신한금융그룹 Family site (shinhangroup.com 하단 목록 기준) */
static final String DEFAULT_SITES = String.join("\n",
"신한은행=http://www.shinhan.com",
"신한카드=http://www.shinhancard.com",
"신한투자증권=http://www.shinhansec.com",
"신한라이프=http://www.shinhanlife.co.kr",
"신한캐피탈=http://www.shcap.co.kr",
"신한자산운용=https://www.shinhanfund.com",
"제주은행=https://www.jejubank.co.kr",
"신한저축은행=http://www.shinhansavings.co.kr",
"신한자산신탁=http://www.shinhantrust.kr",
"신한DS=http://www.shinhansys.co.kr",
"신한펀드파트너스=https://www.shinhanfundpartners.com",
"신한리츠운용=http://shinhanrem.com",
"신한벤처투자=http://www.shinhanvc.com",
"신한EZ손해보험=http://www.shinhanez.co.kr",
"신한장학재단=http://www.shsf.or.kr",
"신한금융희망재단=http://www.shinhanfoundation.or.kr");
private final PortalPropertyService portalPropertyService;
/** 셀렉트 첫 항목에 노출할 라벨 */
@Transactional
public String getLabel() {
String label = portalPropertyService.getOrCreateProperty(GROUP, NAME_LABEL, DEFAULT_LABEL, DESC_LABEL);
return (label == null || label.trim().isEmpty()) ? DEFAULT_LABEL : label.trim();
}
/** 셀렉트에 노출할 사이트 목록. 파싱 결과가 없으면 빈 리스트(푸터에서 셀렉트 미노출) */
@Transactional
public List<RelatedSite> getSites() {
return parse(portalPropertyService.getOrCreateProperty(GROUP, NAME_SITES, DEFAULT_SITES, DESC_SITES));
}
/**
* {@code 이름=URL} 줄 목록을 파싱한다. 잘못된 줄은 건너뛴다.
*/
static List<RelatedSite> parse(String raw) {
if (raw == null || raw.trim().isEmpty()) {
return Collections.emptyList();
}
List<RelatedSite> sites = new ArrayList<>();
for (String rawLine : raw.split("\\r?\\n")) {
String line = rawLine.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
int sep = line.indexOf('=');
if (sep <= 0) {
log.warn("관련 사이트 설정 형식 오류(= 구분자 없음) - 해당 줄 무시: {}", line);
continue;
}
String name = line.substring(0, sep).trim();
String url = line.substring(sep + 1).trim();
if (name.isEmpty() || url.isEmpty()) {
log.warn("관련 사이트 설정 형식 오류(이름 또는 URL 없음) - 해당 줄 무시: {}", line);
continue;
}
if (!isAllowedUrl(url)) {
log.warn("관련 사이트 설정 URL 스킴 불허(http/https 또는 / 로 시작해야 함) - 해당 줄 무시: {}", line);
continue;
}
sites.add(new RelatedSite(name, url));
}
return sites;
}
private static boolean isAllowedUrl(String url) {
String lower = url.toLowerCase();
return lower.startsWith("http://") || lower.startsWith("https://") || url.startsWith("/");
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.djb.menu; package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.common.util.IpAddressMatcher;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -12,11 +13,8 @@ import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** /**
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용. * 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
@@ -24,6 +22,8 @@ import java.util.stream.Collectors;
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록 * <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
* (기본 loopback) + X-Forwarded-For 동반 요청 거부 * (기본 loopback) + X-Forwarded-For 동반 요청 거부
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델). * ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p> * CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
* *
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre> * <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
@@ -37,6 +37,9 @@ public class MenuInternalController {
static final String PROP_GROUP = "Portal"; static final String PROP_GROUP = "Portal";
static final String PROP_ALLOW_IPS = "menu.internal.allow-ips"; static final String PROP_ALLOW_IPS = "menu.internal.allow-ips";
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1"; static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
static final String PROP_ALLOW_IPS_DESCRIPTION =
"메뉴 내부 API(리로드) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, "
+ "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원";
private final MenuService menuService; private final MenuService menuService;
private final PortalPropertyService portalPropertyService; private final PortalPropertyService portalPropertyService;
@@ -66,16 +69,10 @@ public class MenuInternalController {
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.) * 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
*/ */
private boolean isAllowed(HttpServletRequest request) { private boolean isAllowed(HttpServletRequest request) {
String remote = canonicalize(request.getRemoteAddr()); String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null; boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
Set<String> allowed = Arrays.stream(resolveAllowIps().split(",")) if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), remote)) {
.map(String::trim)
.filter(ip -> !ip.isEmpty())
.map(MenuInternalController::canonicalize)
.collect(Collectors.toSet());
if (viaProxy || !allowed.contains(remote)) {
log.warn("메뉴 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy); log.warn("메뉴 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
return false; return false;
} }
@@ -85,15 +82,10 @@ public class MenuInternalController {
private String resolveAllowIps() { private String resolveAllowIps() {
try { try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS, return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
DEFAULT_ALLOW_IPS, "메뉴 내부 API(리로드) 허용 IP 목록(콤마 구분)"); DEFAULT_ALLOW_IPS, PROP_ALLOW_IPS_DESCRIPTION);
} catch (Exception e) { } catch (Exception e) {
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e); log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
return DEFAULT_ALLOW_IPS; return DEFAULT_ALLOW_IPS;
} }
} }
/** IPv6 loopback 표기 통일 */
private static String canonicalize(String ip) {
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
}
} }
+57 -49
View File
@@ -1188,7 +1188,7 @@ hr {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.mobile-drawer .drawer-welcome .btn-drawer-login:hover { .mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0%, 25.7647058824%, 63.5294117647%); background: rgb(0, 65.7, 162);
} }
.mobile-drawer .drawer-welcome.authenticated { .mobile-drawer .drawer-welcome.authenticated {
flex-direction: row; flex-direction: row;
@@ -2499,7 +2499,7 @@ hr {
color: #FFFFFF; color: #FFFFFF;
} }
.btn-success:hover { .btn-success:hover {
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%); background: rgb(83.2897959184, 199.3102040816, 106.493877551);
transform: translateY(-3px); transform: translateY(-3px);
} }
.btn-danger { .btn-danger {
@@ -2507,7 +2507,7 @@ hr {
color: #FFFFFF; color: #FFFFFF;
} }
.btn-danger:hover { .btn-danger:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%); background: rgb(255, 70.8, 70.8);
transform: translateY(-3px); transform: translateY(-3px);
} }
.btn-ghost { .btn-ghost {
@@ -2773,7 +2773,7 @@ hr {
.action-btn-delete:hover { .action-btn-delete:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1); box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%); background: rgb(255, 88.9, 88.9);
} }
.action-btn-delete:active { .action-btn-delete:active {
transform: translateY(0); transform: translateY(0);
@@ -2891,7 +2891,7 @@ hr {
background: #a4d6ea; background: #a4d6ea;
} }
.btn-input-action.btn-change:hover { .btn-input-action.btn-change:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%); background: rgb(131.6625, 199.4303571429, 226.5375);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1); box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
} }
@@ -2966,7 +2966,7 @@ hr {
border: none; border: none;
} }
.btn-action-primary:hover { .btn-action-primary:hover {
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%); background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
transform: translateY(-2px); transform: translateY(-2px);
color: #fff; color: #fff;
} }
@@ -3016,7 +3016,7 @@ hr {
} }
.status-badge.status-processing { .status-badge.status-processing {
background: rgba(255, 217, 61, 0.1); background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%); color: rgb(221.2, 177.8721649485, 0);
} }
.status-badge.status-failed { .status-badge.status-failed {
background: rgba(255, 107, 107, 0.1); background: rgba(255, 107, 107, 0.1);
@@ -3056,7 +3056,7 @@ hr {
} }
.status-badge-header.status-processing { .status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1); background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%); color: rgb(221.2, 177.8721649485, 0);
} }
.badge-sm { .badge-sm {
@@ -4244,7 +4244,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover { .file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1); box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%); background: rgb(255, 88.9, 88.9);
} }
.file-upload-wrapper .file-remove-btn:active { .file-upload-wrapper .file-remove-btn:active {
transform: translateY(0); transform: translateY(0);
@@ -4565,7 +4565,7 @@ select.form-control {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.form-actions--with-withdrawal .withdrawal-link:hover { .form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%); background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
} }
.form-actions--with-withdrawal .withdrawal-link img { .form-actions--with-withdrawal .withdrawal-link img {
width: 22px; width: 22px;
@@ -4720,7 +4720,7 @@ select.form-control {
text-decoration: underline; text-decoration: underline;
} }
.notice-content-box a:hover { .notice-content-box a:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%); color: rgb(0, 65.7, 162);
} }
.form-row--content .form-label-wrapper { .form-row--content .form-label-wrapper {
@@ -5656,7 +5656,7 @@ select.form-control {
font-size: 16px; font-size: 16px;
} }
.drawer-logout-btn:hover { .drawer-logout-btn:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%); background: rgb(255, 70.8, 70.8);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15); box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
} }
@@ -6369,7 +6369,7 @@ select.form-control {
color: #64748b; color: #64748b;
} }
.list-table-btn--default:hover { .list-table-btn--default:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%); background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
} }
.list-table-btn--primary { .list-table-btn--primary {
background-color: #ecf0fa; background-color: #ecf0fa;
@@ -6377,7 +6377,7 @@ select.form-control {
color: #2a69de; color: #2a69de;
} }
.list-table-btn--primary:hover { .list-table-btn--primary:hover {
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%); background-color: rgb(216.7625, 224.8125, 244.9375);
} }
.list-table-btn--secondary { .list-table-btn--secondary {
background-color: #f5f5f4; background-color: #f5f5f4;
@@ -6385,7 +6385,7 @@ select.form-control {
color: #64748b; color: #64748b;
} }
.list-table-btn--secondary:hover { .list-table-btn--secondary:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%); background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
} }
.list-table-btn--danger { .list-table-btn--danger {
background-color: #fbe7e9; background-color: #fbe7e9;
@@ -6393,7 +6393,7 @@ select.form-control {
color: #bb1026; color: #bb1026;
} }
.list-table-btn--danger:hover { .list-table-btn--danger:hover {
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%); background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
} }
.table-pagination { .table-pagination {
@@ -7043,7 +7043,7 @@ select.form-control {
.alert.alert-error { .alert.alert-error {
background: rgba(255, 107, 107, 0.1); background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3); border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(100%, 27.7647058824%, 27.7647058824%); color: rgb(255, 70.8, 70.8);
align-items: center; align-items: center;
} }
.alert.alert-error svg { .alert.alert-error svg {
@@ -7057,7 +7057,7 @@ select.form-control {
.alert.alert-success { .alert.alert-success {
background: rgba(107, 207, 127, 0.1); background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3); border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%); color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
} }
.alert.alert-info { .alert.alert-info {
background: rgba(0, 73, 180, 0.1); background: rgba(0, 73, 180, 0.1);
@@ -8860,8 +8860,9 @@ button.djb-comment-submit:disabled {
} }
.api-showcase .api-cards-container { .api-showcase .api-cards-container {
display: flex; display: flex;
flex-wrap: wrap;
gap: 29px; gap: 29px;
justify-content: center; justify-content: flex-start;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.api-showcase .api-cards-container { .api-showcase .api-cards-container {
@@ -8878,6 +8879,9 @@ button.djb-comment-submit:disabled {
} }
} }
.api-showcase .api-card { .api-showcase .api-card {
flex: 0 0 calc((100% - 87px) / 4);
max-width: calc((100% - 87px) / 4);
box-sizing: border-box;
height: 288px; height: 288px;
background: #FFFFFF; background: #FFFFFF;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -8903,12 +8907,16 @@ button.djb-comment-submit:disabled {
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.api-showcase .api-card { .api-showcase .api-card {
flex: 0 0 calc(50% - 9px);
max-width: calc(50% - 9px);
width: calc(50% - 9px); width: calc(50% - 9px);
min-width: 250px; min-width: 250px;
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.api-showcase .api-card { .api-showcase .api-card {
flex: 1 1 auto;
max-width: 100%;
width: 100%; width: 100%;
height: auto; height: auto;
min-width: unset; min-width: unset;
@@ -11420,10 +11428,10 @@ body.index-page-body {
line-height: 20px; line-height: 20px;
} }
.login-button:hover { .login-button:hover {
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%); background: rgb(25.65, 70.3, 173.85);
} }
.login-button:active { .login-button:active {
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%); background: rgb(24.3, 66.6, 164.7);
} }
.login-button:disabled { .login-button:disabled {
opacity: 0.6; opacity: 0.6;
@@ -11466,10 +11474,10 @@ body.index-page-body {
border-bottom-right-radius: 8px; border-bottom-right-radius: 8px;
} }
.login-links-container .link-btn:hover { .login-links-container .link-btn:hover {
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%); background: rgb(220.61, 227.85, 245.95);
} }
.login-links-container .link-btn:active { .login-links-container .link-btn:active {
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%); background: rgb(205.22, 215.7, 241.9);
} }
.login-alert { .login-alert {
@@ -11978,12 +11986,12 @@ body.index-page-body {
} }
.auth-request-button:hover, .auth-request-button:hover,
.auth-verify-button:hover { .auth-verify-button:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%); background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
transform: none !important; transform: none !important;
} }
.auth-request-button:active, .auth-request-button:active,
.auth-verify-button:active { .auth-verify-button:active {
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%); background: rgb(21.411588785, 146.3125233645, 233.148411215);
} }
.auth-request-button:disabled, .auth-request-button:disabled,
.auth-verify-button:disabled { .auth-verify-button:disabled {
@@ -12032,10 +12040,10 @@ body.index-page-body {
background: #f0f2f5; background: #f0f2f5;
} }
.account-recovery-card .form-actions .cancel-button:hover { .account-recovery-card .form-actions .cancel-button:hover {
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%); background: rgb(225.45, 229.39, 235.3);
} }
.account-recovery-card .form-actions .cancel-button:active { .account-recovery-card .form-actions .cancel-button:active {
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%); background: rgb(210.9, 216.78, 225.6);
} }
.account-recovery-card .form-actions .submit-button { .account-recovery-card .form-actions .submit-button {
color: #FFFFFF; color: #FFFFFF;
@@ -12045,7 +12053,7 @@ body.index-page-body {
background: rgb(6, 54, 125); background: rgb(6, 54, 125);
} }
.account-recovery-card .form-actions .submit-button:active { .account-recovery-card .form-actions .submit-button:active {
background: rgb(0%, 25.7647058824%, 63.5294117647%); background: rgb(0, 65.7, 162);
} }
.account-recovery-card .form-actions .submit-button:disabled { .account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6; opacity: 0.6;
@@ -12281,7 +12289,7 @@ body.index-page-body {
transition: color 0.3s ease; transition: color 0.3s ease;
} }
.result-info-box .info-text .info-link:hover { .result-info-box .info-text .info-link:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%); color: rgb(0, 65.7, 162);
} }
@media (max-width: 576px) { @media (max-width: 576px) {
.result-info-box .info-text { .result-info-box .info-text {
@@ -12747,7 +12755,7 @@ body.index-page-body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 15px;
border-bottom: 1px solid #000; border-bottom: 1px solid #818181;
padding-bottom: 12px; padding-bottom: 12px;
margin-bottom: 32px; margin-bottom: 32px;
} }
@@ -17481,7 +17489,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.btn-copy-action:hover { .btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%); background: rgb(131.6625, 199.4303571429, 226.5375);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.btn-copy-action { .btn-copy-action {
@@ -17508,7 +17516,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.btn-view-secret:hover { .btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%); background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
} }
.btn-view-secret svg { .btn-view-secret svg {
width: 20px; width: 20px;
@@ -17693,7 +17701,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px; border-radius: 8px;
} }
.btn-copy-action:hover { .btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%); background: rgb(131.6625, 199.4303571429, 226.5375);
} }
.btn-view-secret { .btn-view-secret {
width: 100% !important; width: 100% !important;
@@ -17709,7 +17717,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px; height: 16px;
} }
.btn-view-secret:hover { .btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%); background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
} }
#revealedSecretBox { #revealedSecretBox {
width: 100%; width: 100%;
@@ -17982,7 +17990,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0; flex-shrink: 0;
} }
.detail-wrap .dt-btn-copy:hover { .detail-wrap .dt-btn-copy:hover {
background: rgb(74.3529411765%, 90.2296918768%, 100%); background: rgb(189.6, 230.0857142857, 255);
} }
.detail-wrap .dt-btn-copy svg { .detail-wrap .dt-btn-copy svg {
color: #2a69de; color: #2a69de;
@@ -18159,7 +18167,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.detail-wrap .dt-btn-gray:hover { .detail-wrap .dt-btn-gray:hover {
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%); background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
} }
.detail-wrap .dt-btn-red { .detail-wrap .dt-btn-red {
width: 156px; width: 156px;
@@ -18177,7 +18185,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.detail-wrap .dt-btn-red:hover { .detail-wrap .dt-btn-red:hover {
background: rgb(100%, 27.4868759774%, 25.1921568627%); background: rgb(255, 70.0915337423, 64.24);
} }
.detail-wrap .dt-btn-blue { .detail-wrap .dt-btn-blue {
width: 156px; width: 156px;
@@ -19751,7 +19759,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.btn-inquiry-list:hover { .btn-inquiry-list:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%); background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
} }
.btn-inquiry-list:active { .btn-inquiry-list:active {
transform: scale(0.98); transform: scale(0.98);
@@ -19784,7 +19792,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.btn-inquiry-edit:hover { .btn-inquiry-edit:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%); background: rgb(0, 69.35, 171);
} }
.btn-inquiry-edit:active { .btn-inquiry-edit:active {
transform: scale(0.98); transform: scale(0.98);
@@ -19817,7 +19825,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.btn-inquiry-delete:hover { .btn-inquiry-delete:hover {
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%); background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
} }
.btn-inquiry-delete:active { .btn-inquiry-delete:active {
transform: scale(0.98); transform: scale(0.98);
@@ -19889,7 +19897,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px; margin-left: 8px;
} }
.file-upload-inline .btn-remove-file-inline:hover { .file-upload-inline .btn-remove-file-inline:hover {
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%); background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
} }
.file-upload-inline .btn-remove-file-inline svg { .file-upload-inline .btn-remove-file-inline svg {
width: 12px; width: 12px;
@@ -19921,7 +19929,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.file-upload-inline .btn-file-attach:hover { .file-upload-inline .btn-file-attach:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%); background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
} }
.file-upload-inline .btn-file-attach svg { .file-upload-inline .btn-file-attach svg {
width: 22px; width: 22px;
@@ -19981,7 +19989,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none; border: none;
} }
.inquiry-form-container .form-actions .btn-secondary:hover { .inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%); background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
} }
.inquiry-form-container .form-actions .btn-primary { .inquiry-form-container .form-actions .btn-primary {
background: #0049b4; background: #0049b4;
@@ -19989,7 +19997,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none; border: none;
} }
.inquiry-form-container .form-actions .btn-primary:hover { .inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%); background: rgb(0, 69.35, 171);
} }
.inquiry-form-container .file-upload-inline .file-input-display { .inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px; min-height: 50px;
@@ -20778,7 +20786,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer; cursor: pointer;
} }
.djb-board-write-container .form-actions .btn-submit:hover { .djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%); background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit { .djb-board-write-container .form-actions .btn-submit {
@@ -20900,7 +20908,7 @@ input[type=checkbox]:checked + .custom-checkbox {
background: none; background: none;
padding: 0 0 16px 0; padding: 0 0 16px 0;
border-radius: 0; border-radius: 0;
border-bottom: 1px solid #212529; border-bottom: 1px solid #818181;
margin-bottom: 32px; margin-bottom: 32px;
} }
.org-section-header--agreement h3 { .org-section-header--agreement h3 {
@@ -21314,7 +21322,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.org-file-remove:hover { .org-file-remove:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%); background: rgb(255, 70.8, 70.8);
} }
.org-file-notice { .org-file-notice {
@@ -22326,7 +22334,7 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
.status-indicator.status-active { .status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1); background-color: rgba(107, 207, 127, 0.1);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%); color: rgb(83.2897959184, 199.3102040816, 106.493877551);
} }
.status-indicator.status-active .status-dot { .status-indicator.status-active .status-dot {
background-color: #6BCF7F; background-color: #6BCF7F;
@@ -25997,7 +26005,7 @@ input[type=checkbox]:checked + .custom-checkbox {
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 16px; padding-bottom: 16px;
border-bottom: 1.5px solid #4a4a4a; border-bottom: 1.5px solid #818181;
} }
.step1-wrap .s1-form-card .webhook-card-head .head-title-group { .step1-wrap .s1-form-card .webhook-card-head .head-title-group {
display: flex; display: flex;
File diff suppressed because one or more lines are too long
@@ -859,8 +859,9 @@
.api-cards-container { .api-cards-container {
display: flex; display: flex;
flex-wrap: wrap;
gap: 29px; gap: 29px;
justify-content: center; justify-content: flex-start;
//@include respond-to('lg') { //@include respond-to('lg') {
// gap: 22px; // gap: 22px;
@@ -881,6 +882,10 @@
} }
.api-card { .api-card {
// 1행 4열 고정 (gap 29px * 3 = 87px)
flex: 0 0 calc((100% - 87px) / 4);
max-width: calc((100% - 87px) / 4);
box-sizing: border-box;
height: 288px; height: 288px;
background: #FFFFFF; background: #FFFFFF;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -909,12 +914,16 @@
} }
@include respond-to('md') { @include respond-to('md') {
flex: 0 0 calc(50% - 9px);
max-width: calc(50% - 9px);
width: calc(50% - 9px); width: calc(50% - 9px);
min-width: 250px; min-width: 250px;
} }
@include respond-to('sm') { @include respond-to('sm') {
// Figma 모바일: 144px width, auto height, padding 18px 17px // Figma 모바일: 144px width, auto height, padding 18px 17px
flex: 1 1 auto;
max-width: 100%;
width: 100%; width: 100%;
height: auto; height: auto;
min-width: unset; min-width: unset;
@@ -55,7 +55,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 15px;
border-bottom: 1px solid #000; border-bottom: 1px solid #818181;
padding-bottom: 12px; padding-bottom: 12px;
margin-bottom: 32px; margin-bottom: 32px;
@@ -137,7 +137,7 @@
background: none; background: none;
padding: 0 0 $spacing-md 0; padding: 0 0 $spacing-md 0;
border-radius: 0; border-radius: 0;
border-bottom: 1px solid #212529; border-bottom: 1px solid #818181;
margin-bottom: $spacing-xl; margin-bottom: $spacing-xl;
h3 { h3 {
@@ -654,7 +654,7 @@ $wh-bg-soft: #f9f9f9;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 16px; padding-bottom: 16px;
border-bottom: 1.5px solid #4a4a4a; border-bottom: 1.5px solid #818181;
.head-title-group { .head-title-group {
display: flex; display: flex;
@@ -226,8 +226,8 @@
</div> </div>
<div class="api-cards-container"> <div class="api-cards-container">
<!-- Services 데이터를 반복하여 API 카드 표시 (최대 4개) --> <!-- main.service.list 에 등록된 서비스 전체 표시 (1행 4열, 초과분은 다음 행으로) -->
<div class="api-card" th:each="service, iterStat : ${services}" th:if="${iterStat.index < 4}" <div class="api-card" th:each="service : ${services}"
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|"> th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3> <h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
<p class="card-description"> <p class="card-description">
@@ -6,34 +6,7 @@
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<section class="service-intro"> <section class="service-intro">
<h1 class="service-intro__title">DJBank 개발자포탈 소개</h1> <h1 class="service-intro__title">DJBank API Portal 소개</h1>
<p class="service-intro__lead">기업의 업무 흐름 속으로, 금융이 흘러 들어갑니다.</p>
<p class="service-intro__desc">
제주은행의 디지털 기업금융 특화 브랜드 'DJ Bank'의 금융 서비스를<br>
핀테크 기업·개발자·ERP/SaaS 파트너가 손쉽게 연결해 활용할 수 있도록 지원하는<br>
통합 Open API 포털입니다.
</p>
<div class="service-intro__spacer" aria-hidden="true"></div>
<h2 class="service-intro__section-title">구축 배경</h2>
<div class="service-intro__section-body">
<p>기존 기업금융은 서류 부담, 시간 지연, 대면중심 절차의 한계가 있었습니다. 또한 폐쇄적인 금융시스템 안에서 핀테크·SaaS 기업이 금융 기능을 자사 서비스에 결합하기 어려웠습니다.</p>
<p>DJ Bank는 ERP 데이터와 AI를 결합한 자율형 금융 플랫폼을 지향하며, 이 비전을 실현하기 위해 개방형 API 생태계를 구축했습니다. 기업이 본업의 흐름을 끊지 않고 금융을 이용할 수 있도록, 핵심 금융 자원을 파트너와 개발자에게 개방합니다.</p>
</div>
<div class="service-intro__spacer" aria-hidden="true"></div>
<h2 class="service-intro__section-title">제공 API 카테고리</h2>
<ul class="service-intro__list">
<li>계좌 · 수신 - 법인 계좌개설·조회, 법인 파킹통장 연계</li>
<li>여신 · 대출 - ERP 연계 매출채권 담보대출, AX 솔루션자금</li>
<li>신용 · 평가 - 대안신용평가 전략모형 기반 기업 신용 조회</li>
<li>이체 · 결제 - 펌뱅킹, 실시간이체, 결제 API</li>
<li>인증 · 보안 - OAuth 2.0, 전자서명, 마이데이터 인증</li>
</ul>
</section> </section>
</th:block> </th:block>
</body> </body>
@@ -114,7 +114,6 @@
<input type="checkbox" name="eventTypes" th:value="${et.code}" <input type="checkbox" name="eventTypes" th:value="${et.code}"
th:checked="${#lists.contains(webhookModification.eventTypes, et.code)}"> th:checked="${#lists.contains(webhookModification.eventTypes, et.code)}">
<span class="eventtype-name" th:text="${et.name}">이벤트명</span> <span class="eventtype-name" th:text="${et.name}">이벤트명</span>
<span class="eventtype-code" th:text="${et.code}">CODE</span>
</label> </label>
</div> </div>
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p> <p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
@@ -24,18 +24,31 @@
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p> <p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div> </div>
<div class="footer-right"> <div class="footer-right">
<div class="footer-related-sites"> <!-- 관련 사이트: PortalProperty(Portal/footer.related-sites[.label])로 제어. 목록이 비면 미노출 -->
<select class="related-sites-select"> <div class="footer-related-sites" th:if="${!#lists.isEmpty(relatedSites)}">
<option>DJBank 관련 사이트</option> <select class="related-sites-select" data-related-sites th:aria-label="${relatedSitesLabel}">
<option>DJBank 홈페이지</option> <option value="" th:text="${relatedSitesLabel}">DJBank 관련 사이트</option>
<option>DJBank 인터넷뱅킹</option> <option th:each="site : ${relatedSites}" th:value="${site.url}" th:text="${site.name}"></option>
<option>DJBank 모바일뱅킹</option>
</select> </select>
</div> </div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p> <p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div> </div>
</div> </div>
</div> </div>
<script th:inline="none">
// 관련 사이트 셀렉트: 선택 시 새 창으로 이동 후 라벨(첫 항목)로 되돌린다.
(function () {
document.addEventListener('change', function (e) {
var el = e.target;
if (!el || !el.hasAttribute || !el.hasAttribute('data-related-sites')) return;
var url = el.value;
el.selectedIndex = 0;
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
});
})();
</script>
</footer> </footer>
</body> </body>