앱 수정 최종 반영(2FA) 로직 추가:

- 최종 저장 시 2FA 통과권 요구 로직 추가 및 중복 인증 방지
- Step2 2FA 팝업 및 프론트엔드 2FA 검증 로직 반영
- 불필요한 CallBack URL 필드 제거 및 UI 문구 수정
This commit is contained in:
Rinjae
2026-07-28 16:38:37 +09:00
parent 68f8c07fc0
commit d3e6249a41
5 changed files with 63 additions and 24 deletions
@@ -13,6 +13,9 @@ import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO; import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient; import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade; import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper; import com.eactive.apim.portal.common.util.ApiServiceHelper;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -82,6 +85,8 @@ public class MyAppController {
private final ApiServiceHelper apiServiceHelper; private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector; private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient; private final AdminGatewayClient adminGatewayClient;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
@@ -893,6 +898,8 @@ public class MyAppController {
setupStepModel(model, 2); setupStepModel(model, 2);
model.addAttribute("apiServices", apiServices); model.addAttribute("apiServices", apiServices);
model.addAttribute("modification", modification); model.addAttribute("modification", modification);
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
return new ModelAndView(API_KEY_MODIFY_STEP2); return new ModelAndView(API_KEY_MODIFY_STEP2);
} }
@@ -927,6 +934,7 @@ public class MyAppController {
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis, @RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification, @ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus, SessionStatus sessionStatus,
HttpSession session,
RedirectAttributes redirectAttributes) { RedirectAttributes redirectAttributes) {
// 1단계가 완료되었는지 검증 // 1단계가 완료되었는지 검증
@@ -950,6 +958,14 @@ public class MyAppController {
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId()); return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
} }
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
// 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
if (isAppModifyTwofaRequired()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
try { try {
@@ -1021,6 +1037,12 @@ public class MyAppController {
} }
} }
/** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
private boolean isAppModifyTwofaRequired() {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
}
} }
@@ -11,7 +11,8 @@ import java.util.Set;
* <p>검증 레벨(완화 정책)</p> * <p>검증 레벨(완화 정책)</p>
* <ul> * <ul>
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는 * <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li> * AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지 * <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li> * ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
* </ul> * </ul>
@@ -43,8 +44,8 @@ public final class StepUpProtectedPaths {
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret"; public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
/** 앱 해지 신청 (AJAX POST) */ /** 앱 해지 신청 (AJAX POST) */
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete"; public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
/** 앱 정보 수정 페이지 진입 (GET) */ /** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1"; public static final String APP_MODIFY_COMMIT = "/myapikey/modify/step2";
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */ /** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage"; public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */ /** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
@@ -63,7 +64,7 @@ public final class StepUpProtectedPaths {
static { static {
Map<String, String> keys = new LinkedHashMap<>(); Map<String, String> keys = new LinkedHashMap<>();
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret"); keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
keys.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify"); keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete"); keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
keys.put(MYPAGE, KEY_PREFIX + "mypage"); keys.put(MYPAGE, KEY_PREFIX + "mypage");
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change"); keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
@@ -71,7 +72,7 @@ public final class StepUpProtectedPaths {
Map<String, Level> levels = new LinkedHashMap<>(); Map<String, Level> levels = new LinkedHashMap<>();
levels.put(REVEAL_SECRET, Level.TWO_FACTOR); levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
levels.put(APP_MODIFY_STEP1, Level.TWO_FACTOR); levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR); levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
levels.put(MYPAGE, Level.PASSWORD); levels.put(MYPAGE, Level.PASSWORD);
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR); levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
@@ -79,10 +80,11 @@ public final class StepUpProtectedPaths {
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만. // 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외 // - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외 // - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
Set<String> guarded = new java.util.LinkedHashSet<>(); Set<String> guarded = new java.util.LinkedHashSet<>();
guarded.add(REVEAL_SECRET); guarded.add(REVEAL_SECRET);
guarded.add(APP_MODIFY_STEP1);
guarded.add(APP_KEY_DELETE); guarded.add(APP_KEY_DELETE);
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded); INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
} }
@@ -143,13 +143,6 @@
</div> </div>
</div> </div>
<!-- Call Back URL -->
<div class="s1-field">
<label class="s1-label">Call Back URL</label>
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
placeholder="URL을 입력해 주세요.">
</div>
<!-- 화이트 리스트 --> <!-- 화이트 리스트 -->
<div class="s1-field"> <div class="s1-field">
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label> <label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
@@ -357,7 +350,6 @@
form.addEventListener('submit', function (e) { form.addEventListener('submit', function (e) {
const name = document.getElementById('appName').value.trim(); const name = document.getElementById('appName').value.trim();
const desc = textarea.value.trim(); const desc = textarea.value.trim();
const url = document.getElementById('callbackUrl').value.trim();
if (!name) { if (!name) {
e.preventDefault(); e.preventDefault();
@@ -372,15 +364,6 @@
textarea.focus(); textarea.focus();
return; return;
} }
if (url) {
try { new URL(url); } catch (_) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
document.getElementById('callbackUrl').focus();
return;
}
}
}); });
}); });
</script> </script>
@@ -111,6 +111,38 @@
</div> </div>
</div> </div>
</div> </div>
<script th:if="${error}" th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
customPopups.showAlert(/*[[${error}]]*/ '');
});
</script>
<script th:inline="javascript">
// 반영 직전 2FA: twofaRequired 면 최종 저장 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
// api-selector.js 의 submit 리스너(선택 검증·hidden 동기화)가 먼저 실행된 뒤 동작한다.
// "이전" 버튼(btnPrevStep)은 form.submit() 직접 호출이라 submit 이벤트를 타지 않음 → 2FA 미적용.
document.addEventListener('DOMContentLoaded', function () {
var twofaRequired = /*[[${twofaRequired}]]*/ false;
if (!twofaRequired) return;
var form = document.getElementById('apiSelectorForm');
if (!form) return;
form.addEventListener('submit', function (e) {
// 앞선 리스너가 검증 실패로 막았거나(미선택 등) 이미 취소된 제출이면 개입하지 않는다.
if (e.defaultPrevented) return;
e.preventDefault();
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
TwoFactorAuth.open({
mode: 'stepup',
purpose: '/myapikey/modify/step2',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onCancel: function () { /* 사용자 취소 — step2 유지 */ }
});
});
});
</script>
</th:block> </th:block>
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 --> <!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
@@ -151,7 +151,7 @@
</div> </div>
<div class="s3-message-wrapper"> <div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 수정이 완료되었습니다.</h1> <h1 class="s3-success-title">앱 수정 신청이 완료되었습니다.</h1>
<p class="s3-success-desc"> <p class="s3-success-desc">
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span> <span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
<br> <br>