diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java b/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java index 44d2c26..8a8c7c1 100644 --- a/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java +++ b/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java @@ -2,8 +2,6 @@ package com.eactive.apim.portal.apps.auth; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import lombok.RequiredArgsConstructor; -import org.springframework.core.env.Environment; -import org.springframework.core.env.Profiles; import org.springframework.stereotype.Component; /** @@ -12,10 +10,18 @@ import org.springframework.stereotype.Component; *
그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청 * 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml * {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로, - * {@link TwoFactorProperties} 와 동일한 {@code getOrCreateProperty} 패턴을 따른다.
+ * {@link com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties} 와 동일한 + * {@code getOrCreateProperty} 패턴을 따른다. * - *prod 프로파일에서는 DB 값과 무관하게 항상 false 를 반환한다(운영 환경 인증번호 노출 금지). - * 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.
+ *운영(prod)에서도 DB 값만으로 켤 수 있다. 대신 종료일 + * {@code auth.test-notice.prod-until}({@code yyyy-MM-dd})을 함께 지정해야 하며, 날짜가 지나면 + * {@link TestNoticeWindow} 가 자동으로 닫는다. 종료일 미설정/형식 오류면 운영에서는 열리지 않는다. + * 운영 오픈 전 UMS 실발송 미연계 기간을 위한 한시 스위치다.
+ * + *이 판정은 {@link com.eactive.apim.portal.common.breadcrumb.GlobalControllerAdvice} 의 + * {@code @ModelAttribute} 에서 모든 페이지 요청마다 호출된다. PortalPropertyService 에 캐시가 + * 없어 호출마다 DB 조회가 발생하므로 {@value #CACHE_TTL_MILLIS}ms 짧은 캐시를 둔다. + * PTL_PROPERTY 를 바꾸면 최대 그 시간만큼 반영이 늦다.
*/ @Component @RequiredArgsConstructor @@ -23,21 +29,45 @@ public class AuthNoticeProperties { public static final String GROUP = "Portal"; public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled"; + public static final String KEY_TEST_NOTICE_PROD_UNTIL = "auth.test-notice.prod-until"; + + /** 판정 결과 캐시 유효시간(ms) */ + static final long CACHE_TTL_MILLIS = 30_000L; private final PortalPropertyService portalPropertyService; - private final Environment environment; + private final TestNoticeWindow testNoticeWindow; + + private volatile boolean cachedEnabled; + /** 캐시 갱신 시각(ms). 0 이면 미조회 */ + private volatile long cachedAt; /** * 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용). - * prod 환경에서는 property 값과 무관하게 항상 false. + * 운영에서는 종료일({@link #KEY_TEST_NOTICE_PROD_UNTIL})까지만 참이 된다. */ public boolean isTestNoticeEnabled() { - if (environment.acceptsProfiles(Profiles.of("prod"))) { + long now = System.currentTimeMillis(); + long at = cachedAt; + if (at != 0L && now - at < CACHE_TTL_MILLIS) { + return cachedEnabled; + } + boolean enabled = resolveEnabled(); + cachedEnabled = enabled; + cachedAt = now; + return enabled; + } + + private boolean resolveEnabled() { + // 기본값 false - row 가 없는 환경(신규 운영 DB 등)에서 자동으로 켜지지 않도록 한다. + String value = portalPropertyService.getOrCreateProperty( + GROUP, KEY_TEST_NOTICE_ENABLED, "false", + "인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)"); + if (value == null || !"true".equalsIgnoreCase(value.trim())) { return false; } - String value = portalPropertyService.getOrCreateProperty( - GROUP, KEY_TEST_NOTICE_ENABLED, "true", - "인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)"); - return value != null && "true".equalsIgnoreCase(value.trim()); + String until = portalPropertyService.getOrCreateProperty( + GROUP, KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET, + "운영(prod)에서 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단"); + return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED); } } diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/TestNoticeWindow.java b/src/main/java/com/eactive/apim/portal/apps/auth/TestNoticeWindow.java new file mode 100644 index 0000000..25dac86 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/auth/TestNoticeWindow.java @@ -0,0 +1,74 @@ +package com.eactive.apim.portal.apps.auth; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +/** + * 인증번호 화면 노출(테스트 안내) 스위치의 운영 환경 사용 기한 판정기. + * + *운영 오픈 전에는 UMS/EAI 실발송 연계가 끝나지 않아 인증번호가 실제로 도달하지 않을 수 있다. + * 이 기간에는 prod 에서도 인증번호를 화면에 노출해야 테스트가 가능하다. 다만 스위치 끄기를 잊으면 + * 인증번호가 상시 노출되므로, prod 에서는 종료일을 명시한 경우에만 열어 주고 그 날짜가 지나면 + * PTL_PROPERTY 값과 무관하게 자동으로 닫는다.
+ * + *이메일/휴대폰 인증({@link AuthNoticeProperties})과 2차 인증 + * ({@code TwoFactorProperties})이 같은 규칙을 쓰도록 판정을 이 클래스로 모은다. + * 종료일 프로퍼티 값은 {@code yyyy-MM-dd} 형식이며, 미설정을 뜻하는 기본값은 {@value #UNSET} + * ({@code PTL_PROPERTY.property_value} 가 NOT NULL 이라 빈 문자열을 기본값으로 쓸 수 없다).
+ */ +@Component +@Slf4j +public class TestNoticeWindow { + + /** 종료일 미설정을 뜻하는 기본값. prod 에서는 이 값이면 닫힘 */ + public static final String UNSET = "none"; + + private final Environment environment; + + public TestNoticeWindow(Environment environment) { + this.environment = environment; + } + + /** + * @param untilValue 종료일 문자열({@code yyyy-MM-dd}). PTL_PROPERTY 값 원본 + * @param subject 로그 식별용 스위치 이름 + * @return 지금 인증번호를 화면에 노출해도 되는 기간인지 여부 + */ + public boolean isOpen(String untilValue, String subject) { + if (!environment.acceptsProfiles(Profiles.of("prod"))) { + return true; + } + LocalDate until = parseDate(untilValue); + if (until == null) { + return false; + } + if (LocalDate.now().isAfter(until)) { + return false; + } + // 운영에서 인증번호가 화면에 노출되는 상태 - 흔적을 남긴다. + log.warn("운영 환경 인증번호 노출 활성 상태 - {} (종료일 {})", subject, until); + return true; + } + + private static LocalDate parseDate(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + try { + return LocalDate.parse(value.trim()); + } catch (DateTimeParseException e) { + log.warn("인증번호 노출 종료일 형식 오류(yyyy-MM-dd 필요) - {}", value); + return null; + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java index abe8ea9..1826a06 100644 --- a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java +++ b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java @@ -1,5 +1,6 @@ package com.eactive.apim.portal.apps.auth.twofactor; +import com.eactive.apim.portal.apps.auth.TestNoticeWindow; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; import lombok.RequiredArgsConstructor; @@ -25,9 +26,11 @@ public class TwoFactorProperties { public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds"; public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit"; public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled"; + public static final String KEY_TEST_NOTICE_PROD_UNTIL = "two-factor.test-notice.prod-until"; public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled"; private final PortalPropertyService portalPropertyService; + private final TestNoticeWindow testNoticeWindow; /** 로그인 2FA 활성화 여부 */ public boolean isLoginEnabled() { @@ -91,9 +94,21 @@ public class TwoFactorProperties { return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5); } - /** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */ + /** + * 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용). + * + *운영(prod)에서는 종료일 {@link #KEY_TEST_NOTICE_PROD_UNTIL}({@code yyyy-MM-dd})을 함께 + * 지정한 경우에만 참이 되고, 날짜가 지나면 {@link TestNoticeWindow} 가 자동으로 닫는다. + * 종료일 미설정/형식 오류면 운영에서는 열리지 않는다. 이메일/휴대폰 인증 + * ({@code auth.test-notice.*})과 동일한 규칙이다.
+ */ public boolean isTestNoticeEnabled() { - return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)")); + if (!parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"))) { + return false; + } + String until = resolve(KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET, + "운영(prod)에서 2차 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단"); + return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED); } private String resolve(String key, String defaultValue, String description) {