본인 인증 추가 기능 및 UI 개선:
- Step-up 비밀번호 확인 페이지 구현 - 기존 비밀번호 확인 로직 제거 및 반영 직전 2FA 추가 - 보호 경로 완화 정책(PASSWORD/TWO_FACTOR) 및 경로 관리 확장
This commit is contained in:
@@ -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
|
||||
|
||||
+6
-16
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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;
|
||||
}
|
||||
}
|
||||
+74
-28
@@ -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 를 반환한다.
|
||||
* (매번 인증 정책 — 통과권은 즉시 소멸)
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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";
|
||||
}
|
||||
|
||||
// 사용자 이메일 주소를 모델에 추가
|
||||
|
||||
+55
@@ -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());
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
@@ -316,6 +316,9 @@ page:
|
||||
api_detail:
|
||||
name: "API 상세보기"
|
||||
path: "/apis/detail"
|
||||
api_testbed:
|
||||
name: "테스트베드"
|
||||
path: "/apis/detail/testbed"
|
||||
community:
|
||||
name: 고객지원
|
||||
path: "#"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user