Webhook API 표시 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- WebhookDTO에 API 이름 포함 필드 추가(WebhookApiDTO)
- 프로필 활성화 뱃지 CSS 수정(고정 위치, 크기 축소) - 정적 자원 서빙 경로 설정 개선(staticBase 적용)
This commit is contained in:
Rinjae
2026-08-24 10:37:12 +09:00
parent 1ea2c89776
commit b41a81bda3
17 changed files with 140 additions and 37 deletions
@@ -1,9 +1,12 @@
package com.eactive.apim.portal.apps.auth.twofactor; package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.facade.UserFacade; import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.annotation.Secured;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -11,6 +14,8 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
/** /**
@@ -28,8 +33,14 @@ import javax.servlet.http.HttpSession;
@RequestMapping("/auth/stepup") @RequestMapping("/auth/stepup")
public class StepUpPasswordController { public class StepUpPasswordController {
/** 비밀번호 재확인 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) */
private static final int MAX_FAIL_COUNT = 5;
/** 연속 실패 횟수 세션 attribute 키 */
private static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT";
private final UserFacade userFacade; private final UserFacade userFacade;
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final UserSessionService userSessionService;
@GetMapping("/password") @GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) { public String page(@RequestParam(required = false) String returnUrl, Model model) {
@@ -44,7 +55,8 @@ public class StepUpPasswordController {
@PostMapping("/password") @PostMapping("/password")
public String verify(@RequestParam String currentPassword, public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl, @RequestParam(required = false) String returnUrl,
HttpSession session, Model model) { HttpSession session, HttpServletRequest request, HttpServletResponse response,
Model model) {
String path = pathOf(returnUrl); String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) { if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/"; return "redirect:/";
@@ -52,16 +64,34 @@ public class StepUpPasswordController {
String loginId = SecurityUtil.getCurrentLoginId(); String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) { if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀 // 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
session.removeAttribute(ATTR_FAIL_COUNT);
twoFactorService.grantStepUpPass(session, path); twoFactorService.grantStepUpPass(session, path);
return "redirect:" + path; return "redirect:" + path;
} }
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다."); // 연속 실패 카운트 증가. 임계치 초과 시 세션을 강제 종료(로그아웃)한다(무차별 대입 방어).
int failCount = incrementFailCount(session);
if (failCount >= MAX_FAIL_COUNT) {
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login?pwFailExceeded=1";
}
model.addAttribute("error",
"현재 비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
model.addAttribute("returnUrl", path); model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword"; return "apps/auth/stepupPassword";
} }
private static int incrementFailCount(HttpSession session) {
Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT);
int next = (count == null ? 0 : count) + 1;
session.setAttribute(ATTR_FAIL_COUNT, next);
return next;
}
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */ /** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
private static String pathOf(String url) { private static String pathOf(String url) {
if (url == null) { if (url == null) {
@@ -66,7 +66,9 @@ public class PortalUserAuthService implements UserDetailsService {
PortalUser portalUser = findByEmailAddr(normalizedUsername); PortalUser portalUser = findByEmailAddr(normalizedUsername);
return buildAuthenticatedUser(portalUser); return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) { } catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."); // 계정 열거(user enumeration) 공격 방지: 비밀번호 불일치(BadCredentialsException, PortalAuthenticationManager)와
// 동일한 문구를 사용해 아이디 존재 여부가 노출되지 않도록 한다.
throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
} }
} }
@@ -57,6 +57,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Value("${app.resource-caching.enabled:false}") @Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled; private boolean resourceCachingEnabled;
// 정적자원 서빙 루트(application.yml: app.web-resources.static-base).
// 기본은 classpath(빌드 산출물), local_rinjaemac 프로파일은 file:${user.dir}/src/main/resources/static/
// 로 오버라이드해 소스 편집이 재빌드 없이 즉시 반영되도록 한다.
@Value("${app.web-resources.static-base:classpath:/static/}")
private String staticBase;
public PortalConfigWebDispatcherServlet(Environment environment, public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService, com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties, com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
@@ -151,15 +157,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override @Override
public void addResourceHandlers(ResourceHandlerRegistry registry) { public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/"); addStaticResourceHandler(registry, "/css/**", "/css/", staticBase + "css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/"); addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", staticBase + "webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/"); addStaticResourceHandler(registry, "/font/**", "/font/", staticBase + "font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/"); addStaticResourceHandler(registry, "/html/**", "/html/", staticBase + "html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/"); addStaticResourceHandler(registry, "/images/**", "/images/", staticBase + "images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/"); addStaticResourceHandler(registry, "/img/**", "/img/", staticBase + "img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/"); addStaticResourceHandler(registry, "/js/**", "/js/", staticBase + "js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/"); addStaticResourceHandler(registry, "/plugins/**", "/plugins/", staticBase + "plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico"); addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", staticBase + "favicon.ico");
} }
/** /**
@@ -23,8 +23,10 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
* Playwright E2E 테스트 정리용 내부 API — local(개인 dev 프로필, 항상 {@code dev} 동반 활성화)과 * Playwright E2E 테스트 정리용 내부 API — 전용 {@code playwright} 프로필에서만 존재한다
* 배포된 dev 서버 양쪽에서만 존재한다({@code @Profile("dev")}, stage/prod 는 빈 자체가 없어 404). * ({@code @Profile("playwright")}). 개인 local 프로필·배포된 dev 서버가 각자
* {@code spring.profiles.include: playwright} 로 이 프로필을 동반 활성화하며, stage/prod 는
* 이를 포함하지 않아 빈 자체가 없어 404.
* *
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p> * <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
* <ol> * <ol>
@@ -41,7 +43,7 @@ import org.springframework.web.bind.annotation.RestController;
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre> * 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
*/ */
@Slf4j @Slf4j
@Profile("dev") @Profile("playwright")
@RestController @RestController
@RequestMapping("/internal/test-cleanup") @RequestMapping("/internal/test-cleanup")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -11,11 +11,11 @@ import org.springframework.transaction.annotation.Transactional;
/** /**
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을 * Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
* 13개 테이블에서 스윕 삭제한다. local(dev 동반 활성화)·dev 서버에서만 동작한다({@link TestCleanupService} * 13개 테이블에서 스윕 삭제한다. 전용 {@code playwright} 프로필에서만 동작한다({@link TestCleanupService}
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다. * 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
*/ */
@Slf4j @Slf4j
@Profile("dev") @Profile("playwright")
@Service @Service
@Transactional @Transactional
public class OrphanCleanupService { public class OrphanCleanupService {
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Repository;
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용, * 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다. * 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
*/ */
@Profile("dev") @Profile("playwright")
@Repository @Repository
class TestCleanupNativeQueries { class TestCleanupNativeQueries {
@@ -29,13 +29,14 @@ import org.springframework.transaction.annotation.Transactional;
/** /**
* Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과 * Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과
* 그에 딸린 데이터를 하드 삭제한다. local(dev 동반 활성화)·dev 서버에서만 존재하는 빈이며, * 그에 딸린 데이터를 하드 삭제한다. 전용 {@code playwright} 프로필에서만 존재하는 빈이며,
* stage/prod 는 {@code @Profile("dev")} 로 빈 자체가 등록되지 않는다. * local/dev 프로필이 {@code spring.profiles.include: playwright} 로 동반 활성화한다.
* stage/prod 는 이를 포함하지 않아 {@code @Profile("playwright")} 로 빈 자체가 등록되지 않는다.
* *
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p> * <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
*/ */
@Slf4j @Slf4j
@Profile("dev") @Profile("playwright")
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional @Transactional
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 구독 대상 API ID/명칭 쌍. 화면 표시용(ID → API명 매핑은 {@code ApiService.findApisForApiIds} 사용).
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebhookApiDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String name;
}
@@ -18,9 +18,12 @@ public class WebhookDTO implements Serializable {
private String secretMasked; private String secretMasked;
private String createdDate; private String createdDate;
/** 구독 API ID 목록. */ /** 구독 API ID 목록(폼 prefill 등 내부용). */
private List<String> apiIds = new ArrayList<>(); private List<String> apiIds = new ArrayList<>();
/** 구독 API ID+명칭 목록(화면 표시용). */
private List<WebhookApiDTO> apis = new ArrayList<>();
/** 구독 EventType(코드+한글명) 목록. */ /** 구독 EventType(코드+한글명) 목록. */
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>(); private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
} }
@@ -1,5 +1,8 @@
package com.eactive.apim.portal.djb.webhook.service; package com.eactive.apim.portal.djb.webhook.service;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.djb.webhook.dto.WebhookApiDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult; import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO; import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO; import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
@@ -45,6 +48,7 @@ public class WebhookService {
private final WebhookSecretGenerator secretGenerator; private final WebhookSecretGenerator secretGenerator;
private final WebhookEventTypeProvider eventTypeProvider; private final WebhookEventTypeProvider eventTypeProvider;
private final WebhookMapper webhookMapper; private final WebhookMapper webhookMapper;
private final ApiService apiService;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public boolean existsByOrg(String orgId) { public boolean existsByOrg(String orgId) {
@@ -175,8 +179,15 @@ public class WebhookService {
WebhookDTO dto = webhookMapper.toDto(request); WebhookDTO dto = webhookMapper.toDto(request);
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK); dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream() List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId) .map(WebhookRequestApi::getApiId)
.collect(Collectors.toList());
dto.setApiIds(apiIds);
Map<String, String> apiNames = apiService.findApisForApiIds(apiIds).stream()
.collect(Collectors.toMap(ApiSpecInfoDto::getApiId, ApiSpecInfoDto::getApiName, (a, b) -> a));
dto.setApis(apiIds.stream()
.map(id -> new WebhookApiDTO(id, apiNames.getOrDefault(id, id)))
.collect(Collectors.toList())); .collect(Collectors.toList()));
Map<String, String> names = eventTypeProvider.asMap(); Map<String, String> names = eventTypeProvider.asMap();
+2
View File
@@ -2,6 +2,8 @@ spring:
config: config:
activate: activate:
on-profile: dev on-profile: dev
# playwright 프로필 합류는 base application.yml 의 spring.profiles.group 으로 처리한다.
# (spring.profiles.include 는 profile-specific 문서에서 금지 - InvalidConfigDataPropertyException)
jpa: jpa:
properties: properties:
hibernate: hibernate:
+12
View File
@@ -41,6 +41,14 @@ spring:
import: import:
- classpath:menu.yml - classpath:menu.yml
- classpath:roles.yml - classpath:roles.yml
profiles:
# dev/local_rinjaemac 단독 기동 시에도 playwright 전용 빈(test-cleanup 내부 API 등)이 뜨도록
# group 으로 자동 합류. (주의: spring.profiles.include 는 profile-specific 문서에서 금지 -
# application-{profile}.yml 의 on-profile 게이트 안에 두면 InvalidConfigDataPropertyException.
# 반드시 이 base 문서에 정의)
group:
dev: playwright
local_rinjaemac: playwright
data: data:
web: web:
pageable: pageable:
@@ -85,6 +93,10 @@ app:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함. # prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching: resource-caching:
enabled: false enabled: false
# 정적자원 서빙 루트. 기본은 classpath(빌드 산출물).
# local_rinjaemac 프로파일은 file: 로 소스 트리를 직접 바라보도록 오버라이드한다.
web-resources:
static-base: 'classpath:/static/'
security: security:
basic: basic:
+9 -5
View File
@@ -824,16 +824,20 @@ hr {
.env-badge { .env-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
margin-left: 10px; position: fixed;
padding: 2px 8px; top: 0;
border-radius: 4px; left: 0;
padding: 1px 6px;
border-radius: 0 0 4px 0;
background: var(--accent-orange); background: var(--accent-orange);
color: var(--white); color: var(--white);
font-size: 11px; font-size: 9px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-transform: uppercase; text-transform: uppercase;
line-height: 1.6; line-height: 1.5;
z-index: 1100;
pointer-events: none;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.env-badge { .env-badge {
File diff suppressed because one or more lines are too long
@@ -157,20 +157,26 @@
} }
// 활성 Spring 프로파일 표시 (prod 제외) // 활성 Spring 프로파일 표시 (prod 제외)
// 데스크톱: 로고 옆 인라인 뱃지 / 모바일·태블릿: 최상단 floating 바(fixed, 레이아웃 미영향) // 데스크톱: 화면 최좌측·최상단 floating 뱃지 / 모바일·태블릿: 최상단 floating 바
// 둘 다 fixed 라 헤더 레이아웃(로고·메뉴 정렬)에는 영향을 주지 않는다.
.env-badge { .env-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
margin-left: 10px; position: fixed;
padding: 2px 8px; top: 0;
border-radius: 4px; left: 0;
padding: 1px 6px;
// 좌상단 모서리에 붙으므로 우/하단만 둥글게
border-radius: 0 0 4px 0;
background: var(--accent-orange); background: var(--accent-orange);
color: var(--white); color: var(--white);
font-size: 11px; font-size: 9px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-transform: uppercase; text-transform: uppercase;
line-height: 1.6; line-height: 1.5;
z-index: 1100;
pointer-events: none;
@media (max-width: 1024px) { @media (max-width: 1024px) {
display: none; display: none;
@@ -39,6 +39,10 @@
<i class="fas fa-info-circle"></i> <i class="fas fa-info-circle"></i>
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span> <span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
</div> </div>
<div th:if="${param.pwFailExceeded}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>비밀번호 확인 5회 실패로 로그아웃되었습니다. 다시 로그인해 주세요.</span>
</div>
<!-- Login Form --> <!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" <form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"
@@ -72,7 +72,7 @@
<div class="webhook-info-group"> <div class="webhook-info-group">
<span class="group-label">대상 API</span> <span class="group-label">대상 API</span>
<div class="badges-row"> <div class="badges-row">
<span class="api-badge" th:each="apiId : *{apiIds}" th:text="${apiId}">API</span> <span class="api-badge" th:each="api : *{apis}" th:text="${api.name}" th:title="${api.id}">API</span>
</div> </div>
</div> </div>