본인 인증 추가 기능 및 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- Step-up 비밀번호 확인 페이지 구현
- 기존 비밀번호 확인 로직 제거 및 반영 직전 2FA 추가
- 보호 경로 완화 정책(PASSWORD/TWO_FACTOR) 및 경로 관리 확장
This commit is contained in:
Rinjae
2026-07-28 11:04:24 +09:00
parent 6c13b6bc63
commit fedd3e4c50
25 changed files with 603 additions and 165 deletions
@@ -39,7 +39,24 @@ public class ApiController {
if (id == null) {
return "redirect:/apis/common";
}
populateDetailModel(id, model);
model.addAttribute("activeTab", "api-info");
return "apps/apis/mainApiDetail";
}
// 테스트베드를 API 정보와 별도 URL로 분리(딥링크·북마크 가능). 미인증 사용자도 페이지 진입은
// 허용하되, 실제 테스트베드(Swagger)는 인증 사용자에게만 렌더하고 미인증에는 로그인 안내를 노출한다.
@GetMapping("/detail/testbed")
public String apidetailTestbed(@RequestParam(value = "id", required = false) String id, ModelMap model) {
if (id == null) {
return "redirect:/apis/common";
}
populateDetailModel(id, model);
model.addAttribute("activeTab", "testbed");
return "apps/apis/mainApiDetail";
}
private void populateDetailModel(String id, ModelMap model) {
ApiSpecInfoDto api = apiService.selectDetail(id);
if (api == null) {
throw new NotFoundException(NOT_FOUND_MESSAGE);
@@ -50,7 +67,7 @@ public class ApiController {
model.addAttribute("apiSpecInfo", api);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("services", searchResult.get("services"));
return "apps/apis/mainApiDetail";
model.addAttribute("authenticated", SecurityUtil.isAuthenticated());
}
@GetMapping
@@ -1,6 +1,5 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
@@ -10,17 +9,18 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* step-up 2FA(민감기능 추가 인증) 가드.
* step-up 2FA(민감기능 추가 인증) 진입 가드.
*
* <p>보호 경로({@link StepUpProtectedPaths}) 진입 시, 유효한 1회용 통과권이 없으면 인증을 요구한다.
* <p>{@link StepUpProtectedPaths#isInterceptorGuarded(String)} 경로 진입 시, 유효한 1회용
* 통과권이 없으면 2FA 를 요구한다.
* <ul>
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
* </ul>
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
*
* <p>비밀번호 강제 변경 상태(pwEnforce/passwordExpired)의 {@code /password/*} 는 제외한다
* (강제 변경 유도 경로 — {@code PasswordChangeEnforcementInterceptor} 가 이미 관장).</p>
* <p>내 정보 변경({@code /mypage}, PASSWORD 레벨)과 비밀번호 반영({@code POST /password/change},
* 반영 직전 2FA)은 각 컨트롤러가 직접 관장하므로 이 인터셉터 대상이 아니다.</p>
*/
public class StepUpAuthInterceptor implements HandlerInterceptor {
@@ -39,7 +39,7 @@ public class StepUpAuthInterceptor implements HandlerInterceptor {
}
String path = request.getServletPath();
if (!StepUpProtectedPaths.isProtected(path)) {
if (!StepUpProtectedPaths.isInterceptorGuarded(path)) {
return true;
}
@@ -54,11 +54,6 @@ public class StepUpAuthInterceptor implements HandlerInterceptor {
return true;
}
// 비밀번호 강제 변경 상태의 /password/* 는 step-up 제외
if (StepUpProtectedPaths.isPasswordPath(path) && isPasswordEnforced(session)) {
return true;
}
// 1회용 통과권 소비 시도 (매번 인증: 있으면 소멸 후 통과)
if (twoFactorService.consumeStepUpPass(session, path)) {
return true;
@@ -82,9 +77,4 @@ public class StepUpAuthInterceptor implements HandlerInterceptor {
response.sendRedirect(request.getContextPath() + "/auth/2fa/challenge?returnUrl=" + encoded);
return false;
}
private boolean isPasswordEnforced(HttpSession session) {
return Boolean.TRUE.equals(session.getAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR))
|| Boolean.TRUE.equals(session.getAttribute("passwordExpired"));
}
}
@@ -0,0 +1,73 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpSession;
/**
* 완화된 step-up(PASSWORD 레벨) 확인 페이지.
*
* <p>{@link StepUpProtectedPaths#isPasswordGated(String)} 경로(예: {@code /mypage})는 2FA 대신
* <b>현재 비밀번호 재확인</b>만 요구한다. 각 컨트롤러가 통과권이 없을 때 이 페이지로 유도하고,
* 확인 성공 시 해당 경로의 통과권을 발급한 뒤 원경로로 복귀시킨다.</p>
*
* <p>returnUrl 은 PASSWORD 레벨 화이트리스트로만 검증·복귀하여 open redirect 를 막는다.</p>
*/
@Controller
@Secured("ROLE_ACCOUNT")
@RequiredArgsConstructor
@RequestMapping("/auth/stepup")
public class StepUpPasswordController {
private final UserFacade userFacade;
private final TwoFactorService twoFactorService;
@GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
}
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
@PostMapping("/password")
public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl,
HttpSession session, Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
}
String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
twoFactorService.grantStepUpPass(session, path);
return "redirect:" + path;
}
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
private static String pathOf(String url) {
if (url == null) {
return null;
}
int q = url.indexOf('?');
return q >= 0 ? url.substring(0, q) : url;
}
}
@@ -3,32 +3,51 @@ package com.eactive.apim.portal.apps.auth.twofactor;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* step-up 2FA 보호 대상 서블릿 경로(화이트리스트) + 지점별 프로퍼티 키 매핑.
* step-up(민감기능 추가 인증) 대상 서블릿 경로 화이트리스트 + 지점별 프로퍼티 키 + 검증 레벨.
*
* <p>인터셉터(진입 차단)와 서비스(통과권 발급 시 대상 검증)가 동일 목록을 공유한다.
* open redirect / 임의 경로 통과권 발급을 막기 위해 반드시 이 집합으로 검증한다.</p>
* <p>검증 레벨(완화 정책)</p>
* <ul>
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li>
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
* </ul>
*
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어한다.
* <p>세 가지 관심사를 분리한다.</p>
* <ol>
* <li>{@link #isTwoFactorPurpose(String)} — 공통 2FA 팝업의 유효 대상(purpose) 화이트리스트.
* open redirect / 임의 purpose 로의 2FA 발송·통과권 발급을 막는 데 쓴다.</li>
* <li>{@link #isInterceptorGuarded(String)} — {@link StepUpAuthInterceptor} 가 진입 시점에
* 자동 차단하는 경로. 비밀번호 변경(반영 직전 확인)·내 정보(별도 확인 페이지)는
* 각 컨트롤러가 직접 관장하므로 여기서 제외한다.</li>
* <li>{@link #isPasswordGated(String)} — 현재 비밀번호 재확인으로 보호하는 경로.</li>
* </ol>
*
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어하며
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
*
* <p>{@code /new_password} 계열(비밀번호 강제 변경 유도)은 제외 대상이므로 여기 없음.
* 강제 접속(세션 pwEnforce/passwordExpired) 시 /password/* 도 인터셉터에서 별도 제외한다.</p>
*/
public final class StepUpProtectedPaths {
/** step-up 검증 레벨(완화 정책) */
public enum Level {
/** 공통 2FA 팝업(인증번호) */
TWO_FACTOR,
/** 현재 비밀번호 재확인만 */
PASSWORD
}
/** Secret 키 조회 (AJAX POST) */
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
/** 앱 해지 신청 (AJAX POST) */
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
/** 앱 정보 수정 페이지 진입 (GET) */
public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1";
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) */
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 진입 - 현재비번 확인 (GET) */
public static final String PASSWORD_VERIFY = "/password/verify";
/** 비밀번호 변경 폼 (GET) */
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
public static final String PASSWORD_CHANGE = "/password/change";
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
@@ -36,26 +55,58 @@ public final class StepUpProtectedPaths {
/** 경로 → 지점별 프로퍼티 키. 삽입 순서 유지(LinkedHashMap) */
private static final Map<String, String> PATH_TO_KEY;
/** 경로 → 검증 레벨 */
private static final Map<String, Level> PATH_TO_LEVEL;
/** 인터셉터가 진입 시점에 자동 차단하는 경로(2FA) */
private static final Set<String> INTERCEPTOR_GUARDED;
static {
Map<String, String> m = new LinkedHashMap<>();
// 비밀번호 변경은 verify/change 두 진입이 한 기능이므로 동일 키 공유
m.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
m.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify");
m.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
m.put(MYPAGE, KEY_PREFIX + "mypage");
m.put(PASSWORD_VERIFY, KEY_PREFIX + "password-change");
m.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
PATH_TO_KEY = Collections.unmodifiableMap(m);
Map<String, String> keys = new LinkedHashMap<>();
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
keys.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify");
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
keys.put(MYPAGE, KEY_PREFIX + "mypage");
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
PATH_TO_KEY = Collections.unmodifiableMap(keys);
Map<String, Level> levels = new LinkedHashMap<>();
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
levels.put(APP_MODIFY_STEP1, Level.TWO_FACTOR);
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
levels.put(MYPAGE, Level.PASSWORD);
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
Set<String> guarded = new java.util.LinkedHashSet<>();
guarded.add(REVEAL_SECRET);
guarded.add(APP_MODIFY_STEP1);
guarded.add(APP_KEY_DELETE);
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
}
private StepUpProtectedPaths() {
}
public static boolean isProtected(String servletPath) {
return servletPath != null && PATH_TO_KEY.containsKey(servletPath);
/** 공통 2FA 팝업의 유효 대상(purpose)인지 — open redirect / 임의 purpose 차단용 */
public static boolean isTwoFactorPurpose(String servletPath) {
return servletPath != null
&& PATH_TO_LEVEL.get(servletPath) == Level.TWO_FACTOR;
}
/** 해당 경로의 지점별 활성화 프로퍼티 키. 보호 경로가 아니면 null */
/** 인터셉터가 진입 시점에 자동 차단하는 경로인지 */
public static boolean isInterceptorGuarded(String servletPath) {
return servletPath != null && INTERCEPTOR_GUARDED.contains(servletPath);
}
/** 현재 비밀번호 재확인으로 보호하는 경로인지(PASSWORD 레벨) */
public static boolean isPasswordGated(String servletPath) {
return servletPath != null && PATH_TO_LEVEL.get(servletPath) == Level.PASSWORD;
}
/** 해당 경로의 지점별 활성화 프로퍼티 키. 대상 경로가 아니면 null */
public static String propertyKeyOf(String servletPath) {
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
}
@@ -64,9 +115,4 @@ public final class StepUpProtectedPaths {
public static String keyPrefix() {
return KEY_PREFIX;
}
/** 비밀번호 강제 변경 상태(pwEnforce/passwordExpired)에서 step-up 을 건너뛸 경로인지 */
public static boolean isPasswordPath(String servletPath) {
return PASSWORD_VERIFY.equals(servletPath) || PASSWORD_CHANGE.equals(servletPath);
}
}
@@ -71,7 +71,7 @@ public class TwoFactorController {
public String challenge(@RequestParam(required = false) String returnUrl, Model model) {
// returnUrl 은 쿼리스트링을 포함할 수 있으므로 경로 부분만 화이트리스트로 검증(open redirect 방지)
String purpose = pathOf(returnUrl);
if (!StepUpProtectedPaths.isProtected(purpose)) {
if (!StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
return "redirect:/";
}
model.addAttribute("returnUrl", returnUrl);
@@ -124,7 +124,7 @@ public class TwoFactorService {
res.setMessage("인증 대상 정보가 없습니다. 다시 시도해주세요.");
return res;
}
if (mode == TwoFactorContext.Mode.STEPUP && !StepUpProtectedPaths.isProtected(purpose)) {
if (mode == TwoFactorContext.Mode.STEPUP && !StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
res.setValid(false);
res.setMessage("허용되지 않은 요청입니다.");
return res;
@@ -353,6 +353,19 @@ public class TwoFactorService {
session.setAttribute(ATTR_STEPUP_PASS_AT, LocalDateTime.now());
}
/**
* 보호 경로 수정 저장 직후 원경로로 되돌아가는 즉시 왕복(예: {@code /mypage} 수정 →
* {@code redirect:/mypage})에서 중복 step-up 을 막기 위해 통과권을 재발급한다.
*
* <p>경로 고정 + TTL({@link #STEPUP_PASS_TTL_SECONDS}s) 로 <b>1회 왕복만</b> 커버하며,
* 이후 새로 {@code /mypage} 에 진입하면 정상적으로 다시 인증을 요구한다("매번 인증" 유지).</p>
*/
public void grantStepUpPass(HttpSession session, String path) {
if (session != null && path != null) {
issueStepUpPass(session, path);
}
}
/**
* 지정 경로에 대한 유효한 1회용 통과권이 있으면 소비(제거)하고 true 를 반환한다.
* (매번 인증 정책 — 통과권은 즉시 소멸)
@@ -30,7 +30,7 @@ public class PartnershipApplicationController {
@GetMapping
public String newPartnershipApplicationForm(Model model, HttpServletRequest request) {
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?redirect=/partnership";
return "redirect:/login?reason=auth&redirect=/partnership";
}
String referer = request.getHeader("Referer");
@@ -59,6 +59,12 @@ public class LoginFinalizer {
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
/**
* 미인증 상태로 보호 페이지 접근 시 저장해 둔 원래 요청 경로(로그인+2FA 완료 후 복귀 대상).
* {@code PortalGlobalExceptionHandler} 가 저장하고 여기서 소비(1회용)한다.
*/
public static final String SESSION_POST_LOGIN_REDIRECT = "postLoginRedirect";
private final PortalUserRepository portalUserRepository;
private final PortalProperties portalProperties;
private final PortalUserLogService userLogService;
@@ -127,6 +133,15 @@ public class LoginFinalizer {
if (decisionToken != null) {
return contextPath + "/signup/decision_process";
}
// 미인증 접근으로 저장해 둔 원래 요청 페이지로 복귀(1회용).
// 단, 강제 유도 흐름(이메일 인증/휴면/비밀번호 변경 — session.redirectUrl 세팅)이 있으면
// 그쪽이 우선이므로 복귀시키지 않고 기본 경로로 보낸다.
String postLoginRedirect = (String) session.getAttribute(SESSION_POST_LOGIN_REDIRECT);
session.removeAttribute(SESSION_POST_LOGIN_REDIRECT);
if (postLoginRedirect != null && session.getAttribute("redirectUrl") == null) {
return contextPath + postLoginRedirect;
}
return contextPath + "/";
}
@@ -150,9 +165,10 @@ public class LoginFinalizer {
"비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
contextPath + "/password/change");
} else if (user.getPasswordChangeDate() == null) {
// 완화 정책: 현재 비밀번호 확인 단계를 제거했으므로 새 비밀번호 폼으로 바로 유도한다.
applyPasswordChangeState(session,
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
contextPath + "/password/verify");
contextPath + "/password/change");
}
}
@@ -35,9 +35,14 @@ public class ApiStatisticsController {
*/
@GetMapping
public String statisticsPage(Model model) {
// 미로그인 접근은 사유를 노출하도록 reason=auth 로 유도(로그인 페이지 안내 배너)
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?reason=auth";
}
PortalOrg org = getPortalOrg();
if (org == null) {
return "redirect:/login";
// 로그인은 했으나 조직이 없는(권한 밖) 사용자는 홈으로
return "redirect:/";
}
String orgId = org.getId();
@@ -1,6 +1,10 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
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.config.PasswordChangeEnforcementInterceptor;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.dto.*;
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
@@ -49,6 +53,8 @@ public class AccountController {
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
@PostMapping("/password/confirm")
@@ -71,8 +77,14 @@ public class AccountController {
}
@GetMapping("/password/change")
public String showNewPasswordPage(Model model) {
public String showNewPasswordPage(Model model, HttpSession session) {
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
// 강제 변경(ENFORCE/만료) 진입 시 "변경/로그아웃" 강제 팝업을 띄운다.
if (isPasswordEnforced(session)) {
model.addAttribute("forcedPasswordReset", true);
}
// 반영 직전 2FA 필요 여부(강제 변경/2FA off 면 불필요) → 폼 JS 분기용
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
return "apps/mypage/passwordChange";
}
@@ -97,6 +109,15 @@ public class AccountController {
RedirectAttributes redirectAttributes, Model model) {
try {
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 폼으로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
if (isPwChangeTwofaRequired(session)
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.PASSWORD_CHANGE)) {
model.addAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
model.addAttribute("twofaRequired", true);
return "apps/mypage/passwordChange";
}
String currentLoginId = SecurityUtil.getCurrentLoginId();
userFacade.updatePassword(currentLoginId, newPassword, confirmPassword);
@@ -122,6 +143,8 @@ public class AccountController {
logger.warn("비밀번호 변경 검증 실패: {}", e.getMessage());
model.addAttribute("error", e.getMessage());
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
// 2FA 통과권은 이미 소비됨 → 재제출 시 다시 2FA 를 요구하도록 플래그 유지
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
return "apps/mypage/passwordChange";
} catch (Exception e) {
// 예기치 못한 오류(트랜잭션 롤백 등) — 원인 추적을 위해 스택은 남기되,
@@ -129,13 +152,21 @@ public class AccountController {
logger.error("비밀번호 변경 처리 중 오류", e);
model.addAttribute("error", "비밀번호 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
return "apps/mypage/passwordChange";
}
}
@GetMapping("/mypage")
public ModelAndView mypage() {
public ModelAndView mypage(HttpSession session) {
// 완화된 step-up(PASSWORD 레벨): 통과권이 없으면 현재 비밀번호 확인 페이지로 유도한다.
// (내 정보 변경은 2FA 대신 비밀번호 재확인만 요구)
if (isMypageStepUpActive()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.MYPAGE)) {
return new ModelAndView("redirect:/auth/stepup/password?returnUrl=" + StepUpProtectedPaths.MYPAGE);
}
ModelAndView mav = new ModelAndView();
try {
@@ -248,6 +279,8 @@ public class AccountController {
return "redirect:/mypage";
} finally {
cleanupAuthSession(session);
// 수정 저장 후 redirect:/mypage 로 되돌아갈 때 step-up 재요구를 막는다(1회 왕복 한정).
twoFactorService.grantStepUpPass(session, StepUpProtectedPaths.MYPAGE);
}
}
@@ -260,6 +293,29 @@ public class AccountController {
}
}
/** 내 정보({@code /mypage}) 완화 step-up(비밀번호 재확인)이 현재 활성인지 */
private boolean isMypageStepUpActive() {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.MYPAGE);
}
/** 비밀번호 강제 변경 상태(ENFORCE/만료)인지 */
private boolean isPasswordEnforced(HttpSession session) {
return Boolean.TRUE.equals(session.getAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR))
|| Boolean.TRUE.equals(session.getAttribute("passwordExpired"));
}
/**
* 비밀번호 변경 반영 직전에 2FA 를 요구할지 여부.
* 전체/지점 step-up 스위치가 켜져 있고 <b>강제 변경 상태가 아닐 때만</b> 요구한다
* (강제/만료 변경은 기존대로 2FA 없이 진행).
*/
private boolean isPwChangeTwofaRequired(HttpSession session) {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.PASSWORD_CHANGE)
&& !isPasswordEnforced(session);
}
@GetMapping("/mypage/org-transfer")
public String showOrgTransferPage(Model model) {
try {
@@ -374,7 +430,7 @@ public class AccountController {
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
if (currentUser == null) {
return "redirect:/login";
return "redirect:/login?reason=auth";
}
// 사용자 이메일 주소를 모델에 추가
@@ -7,6 +7,7 @@ import java.util.Map;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.config.PortalProperties;
@@ -58,6 +59,8 @@ public class PortalGlobalExceptionHandler {
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
// 미로그인 사용자는 로그인 페이지로 유도, 로그인 상태에서의 권한 부족은 오류 안내 페이지로 표시한다.
if (!SecurityUtil.isAuthenticated()) {
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
savePostLoginRedirect(request);
return new ModelAndView("redirect:/login?reason=auth");
}
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
@@ -67,6 +70,58 @@ public class PortalGlobalExceptionHandler {
return modelAndView;
}
/**
* 미인증 상태로 보호 페이지(HTML 화면)에 접근한 GET 요청의 원 경로를 세션에 저장한다.
* 로그인+2FA 완료 후 {@link LoginFinalizer} 가 이 값으로 복귀시킨다(1회용).
* open redirect / 무의미 복귀를 막기 위해 내부 화면 GET 경로만 저장한다.
*/
private void savePostLoginRedirect(HttpServletRequest request) {
if (!"GET".equalsIgnoreCase(request.getMethod())) {
return;
}
// 페이지 네비게이션만 대상(AJAX/데이터 요청 제외)
String accept = request.getHeader("Accept");
if (accept == null || !accept.contains("text/html")) {
return;
}
String uri = request.getRequestURI();
if (uri == null) {
return;
}
String ctx = request.getContextPath();
String path = (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri;
if (path.isEmpty()) {
path = "/";
}
if (!isSafePostLoginPath(path)) {
return;
}
String qs = request.getQueryString();
String target = (qs != null && !qs.isEmpty()) ? path + "?" + qs : path;
request.getSession().setAttribute(LoginFinalizer.SESSION_POST_LOGIN_REDIRECT, target);
}
/** 복귀 대상으로 허용할 내부 경로인지(로그인/인증/에러/정적/액션/홈 제외) */
private boolean isSafePostLoginPath(String path) {
if (path == null || !path.startsWith("/") || path.startsWith("//")) {
return false;
}
return !(path.equals("/")
|| path.startsWith("/login")
|| path.startsWith("/auth/")
|| path.startsWith("/actionLogin")
|| path.startsWith("/actionLogout")
|| path.startsWith("/error")
|| path.startsWith("/css/")
|| path.startsWith("/js/")
|| path.startsWith("/img/")
|| path.startsWith("/webfonts/")
|| path.startsWith("/plugins/")
|| path.startsWith("/api/")
|| path.startsWith("/_proxy")
|| path.startsWith("/favicon"));
}
@ExceptionHandler(value = PortalRedirectException.class)
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
return new ModelAndView(ex.getMessage());
@@ -54,7 +54,8 @@ public class PasswordChangeEnforcementInterceptor implements HandlerInterceptor
return true;
}
String target = request.getContextPath() + "/password/verify";
// 완화 정책: 현재 비밀번호 확인 단계를 제거했으므로 새 비밀번호 폼으로 바로 유도한다.
String target = request.getContextPath() + "/password/change";
// 이미 목적지면 재리다이렉트하지 않는다(무한 루프 방지).
if (request.getRequestURI().equals(target)) {
return true;
+3
View File
@@ -316,6 +316,9 @@ page:
api_detail:
name: "API 상세보기"
path: "/apis/detail"
api_testbed:
name: "테스트베드"
path: "/apis/detail/testbed"
community:
name: 고객지원
path: "#"
+79 -44
View File
@@ -1160,7 +1160,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -2456,7 +2456,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
transform: translateY(-3px);
}
.btn-danger {
@@ -2464,7 +2464,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2730,7 +2730,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2848,7 +2848,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -2923,7 +2923,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
transform: translateY(-2px);
color: #fff;
}
@@ -2966,7 +2966,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3006,7 +3006,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.badge-sm {
@@ -4194,7 +4194,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4515,7 +4515,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4670,7 +4670,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.form-row--content .form-label-wrapper {
@@ -5541,7 +5541,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6254,7 +6254,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6262,7 +6262,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(216.7625, 224.8125, 244.9375);
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6270,7 +6270,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6278,7 +6278,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
}
.table-pagination {
@@ -6928,7 +6928,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(255, 70.8, 70.8);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
align-items: center;
}
.alert.alert-error svg {
@@ -6942,7 +6942,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -11213,6 +11213,8 @@ body.index-page-body {
transition: all 0.3s ease;
position: relative;
bottom: -2px;
display: inline-block;
text-decoration: none;
}
.api-detail-tabs .tab-button:hover {
color: #0049b4;
@@ -11241,6 +11243,39 @@ body.index-page-body {
margin: 24px 0;
}
.testbed-auth-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: calc(32px * 2) 24px;
text-align: center;
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.testbed-auth-gate .testbed-auth-gate__icon {
font-size: 40px;
line-height: 1;
}
.testbed-auth-gate .testbed-auth-gate__title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #1A1A2E;
}
.testbed-auth-gate .testbed-auth-gate__desc {
margin: 0;
font-size: 16px;
color: #64748B;
}
.testbed-auth-gate .btn {
margin-top: 8px;
min-width: 160px;
}
.testbed-app-panel {
margin: 8px 0 0;
padding: 24px;
@@ -11835,10 +11870,10 @@ body.index-page-body {
line-height: 1;
}
.login-button:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.login-button:active {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.login-button:disabled {
opacity: 0.6;
@@ -12363,11 +12398,11 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(147.83125, 206.7151785714, 230.26875);
background: rgb(57.9730392157%, 81.0647759104%, 90.3014705882%);
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12416,20 +12451,20 @@ body.index-page-body {
background: #e5e7eb;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(202.7739130435, 206.7913043478, 214.8260869565);
background: rgb(79.5191815857%, 81.094629156%, 84.2455242967%);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
background: #0049B4;
}
.account-recovery-card .form-actions .submit-button:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12638,7 +12673,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17702,7 +17737,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17729,7 +17764,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
.btn-view-secret svg {
width: 20px;
@@ -17914,7 +17949,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
.btn-view-secret {
width: 100% !important;
@@ -17930,7 +17965,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
#revealedSecretBox {
width: 100%;
@@ -18203,7 +18238,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(189.6, 230.0857142857, 255);
background: rgb(74.3529411765%, 90.2296918768%, 100%);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18354,7 +18389,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18372,7 +18407,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(255, 70.0915337423, 64.24);
background: rgb(100%, 27.4868759774%, 25.1921568627%);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -19847,7 +19882,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -19880,7 +19915,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -19913,7 +19948,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -19985,7 +20020,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -20017,7 +20052,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -20077,7 +20112,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20085,7 +20120,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -20874,7 +20909,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21356,7 +21391,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
}
.org-file-notice {
@@ -22338,7 +22373,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
File diff suppressed because one or more lines are too long
@@ -645,6 +645,9 @@
transition: $transition-base;
position: relative;
bottom: -2px;
// 탭이 <a> 링크로 전환됨(별도 URL) — 링크 기본 스타일 제거
display: inline-block;
text-decoration: none;
&:hover {
color: $primary-blue;
@@ -680,6 +683,44 @@
}
}
// Testbed 미인증 안내 — UI는 노출하되 접근 시 로그인 요구
.testbed-auth-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $spacing-md;
padding: calc(#{$spacing-xl} * 2) $spacing-lg;
text-align: center;
background: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-lg;
box-shadow: $shadow-sm;
.testbed-auth-gate__icon {
font-size: 40px;
line-height: 1;
}
.testbed-auth-gate__title {
margin: 0;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
}
.testbed-auth-gate__desc {
margin: 0;
font-size: $font-size-base;
color: $text-gray;
}
.btn {
margin-top: $spacing-sm;
min-width: 160px;
}
}
// Testbed 앱 선택 패널 (DJPGPT0001)
.testbed-app-panel {
margin: $spacing-sm 0 0;
@@ -55,14 +55,18 @@
<!-- API Content -->
<div class="api-detail-content">
<!-- Tab Navigation -->
<!-- Tab Navigation: API 정보 / 테스트베드는 각각 별도 URL(딥링크). 활성 탭은 서버에서 결정 -->
<div class="api-detail-tabs">
<button class="tab-button active" data-tab="api-info">API 정보</button>
<button class="tab-button" data-tab="testbed">테스트베드</button>
<a class="tab-button"
th:classappend="${activeTab == 'testbed'} ? '' : 'active'"
th:href="@{/apis/detail(id=${apiSpecInfo.apiId})}">API 정보</a>
<a class="tab-button"
th:classappend="${activeTab == 'testbed'} ? 'active' : ''"
th:href="@{/apis/detail/testbed(id=${apiSpecInfo.apiId})}">테스트베드</a>
</div>
<!-- Tab Content -->
<div class="tab-content active" id="api-info-tab">
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
<!-- API Overview Card (Merged with Additional Information) -->
<div class="api-overview-card">
<div class="org-section-header org-section-header--agreement">
@@ -141,26 +145,38 @@
</div>
<!-- Testbed Tab Content -->
<div class="tab-content" id="testbed-tab">
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? 'active' : ''" id="testbed-tab">
<!-- DJPGPT0001: 앱(인증키) 선택 → 인증 정보 자동 주입 -->
<div class="testbed-app-panel" id="appsWrap">
<div class="testbed-app-panel__head">
<span class="testbed-app-panel__title">앱 선택</span>
<span class="testbed-app-panel__desc">테스트할 앱(인증키)을 선택하면 인증 정보가 자동 입력됩니다.</span>
</div>
<div class="testbed-app-field">
<select id="apps">
<option value="">앱을 선택하세요</option>
</select>
</div>
<p class="testbed-app-notice" id="appsNotice" style="display:none;"></p>
<!-- 미인증: 테스트베드는 로그인 후 이용 가능. UI는 노출하되 접근 시 인증 요구 -->
<div class="testbed-auth-gate" th:unless="${authenticated}">
<div class="testbed-auth-gate__icon">🔒</div>
<p class="testbed-auth-gate__title">테스트베드는 로그인 후 이용 가능합니다.</p>
<p class="testbed-auth-gate__desc">로그인하면 본인 앱(인증키)으로 API를 직접 호출·테스트할 수 있습니다.</p>
<a class="btn btn-primary" th:href="@{/login(reason='auth')}">로그인하기</a>
</div>
<div id="swagger-ui"></div>
<!-- 인증: Swagger 테스트베드 -->
<th:block th:if="${authenticated}">
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
<!-- DJPGPT0001: 앱(인증키) 선택 → 인증 정보 자동 주입 -->
<div class="testbed-app-panel" id="appsWrap">
<div class="testbed-app-panel__head">
<span class="testbed-app-panel__title">앱 선택</span>
<span class="testbed-app-panel__desc">테스트할 앱(인증키)을 선택하면 인증 정보가 자동 입력됩니다.</span>
</div>
<div class="testbed-app-field">
<select id="apps">
<option value="">앱을 선택하세요</option>
</select>
</div>
<p class="testbed-app-notice" id="appsNotice" style="display:none;"></p>
</div>
<div id="swagger-ui"></div>
</th:block>
</div>
</div>
@@ -255,42 +271,13 @@
}
});
// Tab functionality
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(function(button) {
button.addEventListener('click', function() {
const targetTab = this.getAttribute('data-tab');
// Remove active class from all buttons and contents
tabButtons.forEach(function(btn) {
btn.classList.remove('active');
});
tabContents.forEach(function(content) {
content.classList.remove('active');
});
// Add active class to clicked button and corresponding content
this.classList.add('active');
document.getElementById(targetTab + '-tab').classList.add('active');
});
});
// Swagger UI functionality for testbed tab
let swaggerInitialized = false;
// 탭(API 정보 / 테스트베드)은 각각 별도 URL의 <a> 링크로 전환된다.
// 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만
// Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후).
const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default';
// Initialize Swagger UI when testbed tab is clicked
tabButtons.forEach(function(button) {
button.addEventListener('click', function() {
const targetTab = this.getAttribute('data-tab');
if (targetTab === 'testbed' && !swaggerInitialized) {
initializeSwagger(currentApiId);
swaggerInitialized = true;
}
});
});
const activeTab = /*[[${activeTab}]]*/ 'api-info';
const isAuthenticated = /*[[${authenticated}]]*/ false;
const testbedActive = (activeTab === 'testbed' && isAuthenticated);
function initializeSwagger(apiId) {
if (!apiId || apiId === 'default') {
@@ -517,10 +504,14 @@
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
}
if (appsSelect) {
appsSelect.addEventListener('change', function () { onAppSelected(this.value); });
// 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화
if (testbedActive) {
if (appsSelect) {
appsSelect.addEventListener('change', function () { onAppSelected(this.value); });
}
initializeSwagger(currentApiId);
loadTestbedContext();
}
loadTestbedContext();
});
</script>
</th:block>
@@ -8,7 +8,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>본인 확인</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main">
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2>
<form th:action="@{/auth/stepup/password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="returnUrl" th:value="${returnUrl}"/>
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
</div>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required autofocus>
</div>
</div>
</div>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${error}]]);
})
</script>
</section>
</body>
</html>
@@ -16,7 +16,14 @@
/* 안내는 상단에서부터 노출(세로 중앙정렬 X → 팝업 뒤에 가려지지 않게) */
.tfa-challenge {
padding: 40px 16px 48px; /* 상단 약간의 여백만 */
/* 상단 = 고정 헤더 높이(80px) + 여백 40px. 이 페이지는 .container 를 직접 쓰므로
.content-wrapper .main-content 의 헤더 오프셋을 못 받아 직접 보정. */
padding: 120px 16px 48px;
}
@media (max-width: 768px) {
.tfa-challenge {
padding-top: 100px; /* 모바일 헤더 최대 60px + 여백 40px */
}
}
.tfa-challenge-card {
text-align: center;
@@ -16,7 +16,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" class="service-keyImg" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
@@ -15,7 +15,7 @@
<a class="nav-link" th:href="@{/my_company_info}">기업 정보 변경</a>
</li>
<li class="nav-item">
<a class="nav-link active" th:href="@{/password/verify}">비밀번호 변경</a>
<a class="nav-link active" th:href="@{/password/change}">비밀번호 변경</a>
</li>
</ul>
<div class="card mt-2">
@@ -79,6 +79,35 @@
customPopups.showAlert([[${error}]]);
})
</script>
<script th:inline="javascript">
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
(function () {
var twofaRequired = /*[[${twofaRequired}]]*/ false;
if (!twofaRequired) return;
var form = document.getElementById('passwordChangeForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var np = document.getElementById('newPassword').value;
var cp = form.querySelector('input[name="confirmPassword"]').value;
if (!np || !cp) { customPopups.showAlert('새 비밀번호를 입력해 주세요.'); return; }
if (np !== cp) { customPopups.showAlert('새 비밀번호가 일치하지 않습니다.'); return; }
if (window.PasswordPolicy && !PasswordPolicy.isValid(np)) {
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
}
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
TwoFactorAuth.open({
purpose: '/password/change',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onCancel: function () { /* 사용자 취소 — 유지 */ }
});
});
})();
</script>
</section>
</body>
</html>
@@ -87,7 +87,7 @@
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
<li><a th:href="@{/password/verify}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
<li><a th:href="@{/password/change}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
</ul>
</div>
</div>
@@ -243,7 +243,7 @@
<li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/password/verify}">비밀번호 변경</a></li>
<li><a th:href="@{/password/change}">비밀번호 변경</a></li>
</ul>
</li>
</ul>
@@ -73,7 +73,7 @@
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
class="service-nav__item">내정보 관리</a>
<a th:href="@{/password/verify}"
<a th:href="@{/password/change}"
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
class="service-nav__item">비밀번호 변경</a>
</th:block>