diff --git a/src/main/java/com/eactive/apim/portal/common/exception/PortalErrorController.java b/src/main/java/com/eactive/apim/portal/common/exception/PortalErrorController.java index 3bc19dc..86a2fe6 100644 --- a/src/main/java/com/eactive/apim/portal/common/exception/PortalErrorController.java +++ b/src/main/java/com/eactive/apim/portal/common/exception/PortalErrorController.java @@ -1,6 +1,8 @@ package com.eactive.apim.portal.common.exception; import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; @@ -16,6 +18,11 @@ import javax.servlet.http.HttpServletRequest; @Controller public class PortalErrorController implements ErrorController { + private final Environment environment; + + public PortalErrorController(Environment environment) { + this.environment = environment; + } @RequestMapping(value = "/error", method = {RequestMethod.POST, RequestMethod.GET}) public ModelAndView handleError(HttpServletRequest request) { @@ -27,10 +34,94 @@ public class PortalErrorController implements ErrorController { } ModelAndView modelAndView = new ModelAndView("error"); modelAndView.addObject("status", status); + + if (isProd()) { + // prod 환경에서는 내부 오류 정보를 노출하지 않고 이전과 동일한 일반 안내 메시지만 표시 + modelAndView.addObject("errorTitle", "일시적인 장애로 서비스가 중단되었습니다."); + modelAndView.addObject("errorDescription", "서비스 이용에 불편을 드려 죄송합니다. 빠른 서비스를 제공할 수 있게 준비하겠습니다."); + } else { + modelAndView.addObject("errorTitle", resolveTitle(status)); + modelAndView.addObject("errorDescription", resolveDescription(status)); + modelAndView.addObject("errorMessage", resolveErrorMessage(request, status)); + } return modelAndView; } return new ModelAndView("redirect:/"); } + private boolean isProd() { + return environment.acceptsProfiles(Profiles.of("prod")); + } + + private String resolveTitle(Object status) { + HttpStatus httpStatus = resolveHttpStatus(status); + if (httpStatus == null) { + return "서비스 처리 중 오류가 발생했습니다."; + } + + if (httpStatus == HttpStatus.FORBIDDEN) { + return "접근이 거부되었습니다."; + } + if (httpStatus == HttpStatus.NOT_FOUND) { + return "요청하신 페이지를 찾을 수 없습니다."; + } + if (httpStatus.is4xxClientError()) { + return "요청을 처리할 수 없습니다."; + } + if (httpStatus.is5xxServerError()) { + return "서비스 처리 중 오류가 발생했습니다."; + } + return httpStatus.getReasonPhrase(); + } + + private String resolveDescription(Object status) { + HttpStatus httpStatus = resolveHttpStatus(status); + if (httpStatus == HttpStatus.FORBIDDEN) { + return "요청이 보안 검증을 통과하지 못했습니다."; + } + if (httpStatus == HttpStatus.NOT_FOUND) { + return "주소가 변경되었거나 삭제되었을 수 있습니다."; + } + if (httpStatus != null && httpStatus.is5xxServerError()) { + return "잠시 후 다시 시도해 주세요."; + } + return "요청 내용을 확인한 뒤 다시 시도해 주세요."; + } + + private String resolveErrorMessage(HttpServletRequest request, Object status) { + Object requestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI); + HttpStatus httpStatus = resolveHttpStatus(status); + StringBuilder message = new StringBuilder(); + + message.append("HTTP ").append(status); + if (httpStatus != null) { + message.append(" ").append(httpStatus.getReasonPhrase()); + } + + if (requestUri != null) { + message.append(" / 요청 경로: ").append(requestUri); + } + + Object exceptionMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE); + if (exceptionMessage != null && !exceptionMessage.toString().trim().isEmpty()) { + message.append(" / 원인: ").append(exceptionMessage); + } else if (httpStatus == HttpStatus.FORBIDDEN) { + message.append(" / 원인: 접근 권한 또는 CSRF 토큰을 확인하세요."); + } + + return message.toString(); + } + + private HttpStatus resolveHttpStatus(Object status) { + if (status instanceof Integer) { + return HttpStatus.resolve((Integer) status); + } + try { + return HttpStatus.resolve(Integer.parseInt(status.toString())); + } catch (NumberFormatException e) { + return null; + } + } + } diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java index dbe1065..bade8b2 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java @@ -13,7 +13,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -79,6 +79,13 @@ public class PortalConfigSecurity { @Bean public SecurityFilterChain filterChain(HttpSecurity http) { try { + // CSRF 토큰을 쿠키(XSRF-TOKEN) 대신 HTTP 세션에 저장한다. + // 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어 + // 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다. + // 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다. + HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository(); + csrfTokenRepository.setHeaderName("X-XSRF-TOKEN"); + http .authenticationManager(portalAuthenticationManager) .formLogin(form -> form @@ -92,9 +99,10 @@ public class PortalConfigSecurity { .logoutRequestMatcher(new AntPathRequestMatcher("/actionLogout.do", "GET")) .logoutSuccessHandler(logoutSuccessHandler)) .csrf(csrf -> csrf - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRepository(csrfTokenRepository) .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*")) .ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate")) + .ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**")) ) // 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용) .sessionManagement(session -> session diff --git a/src/main/resources/static/js/djb/inquiry-comments.js b/src/main/resources/static/js/djb/inquiry-comments.js index 705e6c1..96c1370 100644 --- a/src/main/resources/static/js/djb/inquiry-comments.js +++ b/src/main/resources/static/js/djb/inquiry-comments.js @@ -14,8 +14,9 @@ const counterEl = document.getElementById('djbCommentCharCounter'); function getCsrfToken() { - const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/); - return match ? decodeURIComponent(match[1]) : ''; + // 세션 기반 CSRF: 쿠키 대신 에서 토큰을 읽는다. + const meta = document.querySelector('meta[name="_csrf"]'); + return meta ? meta.getAttribute('content') : ''; } function fetchJson(url, options) { diff --git a/src/main/resources/templates/views/apps/statistics/apiStatistics.html b/src/main/resources/templates/views/apps/statistics/apiStatistics.html index a1606ea..896f9cf 100644 --- a/src/main/resources/templates/views/apps/statistics/apiStatistics.html +++ b/src/main/resources/templates/views/apps/statistics/apiStatistics.html @@ -329,17 +329,10 @@ return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } - // Get CSRF token from cookie + // 세션 기반 CSRF: 쿠키 대신 에서 토큰을 읽는다. function getCsrfToken() { - var name = 'XSRF-TOKEN='; - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = cookies[i].trim(); - if (cookie.indexOf(name) === 0) { - return decodeURIComponent(cookie.substring(name.length)); - } - } - return ''; + var meta = document.querySelector('meta[name="_csrf"]'); + return meta ? meta.getAttribute('content') : ''; } // Validate date range (max 40 days) diff --git a/src/main/resources/templates/views/error.html b/src/main/resources/templates/views/error.html index c1d8f53..5a7d0bd 100644 --- a/src/main/resources/templates/views/error.html +++ b/src/main/resources/templates/views/error.html @@ -210,12 +210,13 @@ -
- 서비스 이용에 불편을 드려 죄송합니다.
- 빠른 서비스를 제공할 수 있게 준비하겠습니다.
+
+ 잠시 후 다시 시도해 주세요.
diff --git a/src/main/resources/templates/views/fragment/djbank/head.html b/src/main/resources/templates/views/fragment/djbank/head.html index 7094f9e..bf91cc8 100644 --- a/src/main/resources/templates/views/fragment/djbank/head.html +++ b/src/main/resources/templates/views/fragment/djbank/head.html @@ -6,6 +6,10 @@ + + + + diff --git a/src/main/resources/templates/views/fragment/djbank/header_container.html b/src/main/resources/templates/views/fragment/djbank/header_container.html index 54ee8ce..c5709ec 100644 --- a/src/main/resources/templates/views/fragment/djbank/header_container.html +++ b/src/main/resources/templates/views/fragment/djbank/header_container.html @@ -278,8 +278,15 @@ } function csrfHeaders() { - var token = getCookie('XSRF-TOKEN'); - return token ? { 'X-XSRF-TOKEN': token } : {}; + // 세션 기반 CSRF: 쿠키 대신 에서 토큰·헤더명을 읽는다. + var tokenMeta = document.querySelector('meta[name="_csrf"]'); + var headerMeta = document.querySelector('meta[name="_csrf_header"]'); + var headers = {}; + if (tokenMeta && tokenMeta.content) { + var headerName = (headerMeta && headerMeta.content) ? headerMeta.content : 'X-XSRF-TOKEN'; + headers[headerName] = tokenMeta.content; + } + return headers; } function formatTime(sec) {