- CSRF 토큰 저장소 쿠키 → 세션 전환 및 클라이언트 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- error 페이지 텍스트 동적 처리 추가 (prod 환경 구분)
- CSRF 메타 데이터 추가 및 JS/AJAX CSRF 로직 일원화
This commit is contained in:
Rinjae
2026-06-22 14:19:32 +09:00
parent 6694715195
commit 5adfd42751
7 changed files with 125 additions and 20 deletions
@@ -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;
}
}
}
@@ -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
@@ -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: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
const meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute('content') : '';
}
function fetchJson(url, options) {
@@ -329,17 +329,10 @@
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// Get CSRF token from cookie
// 세션 기반 CSRF: 쿠키 대신 <meta name="_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)
@@ -210,12 +210,13 @@
</div>
<!-- Error Title -->
<h1 class="error-title">일시적인 장애로 서비스가 중단되었습니다.</h1>
<h1 class="error-title" th:text="${errorTitle ?: '서비스 처리 중 오류가 발생했습니다.'}">
서비스 처리 중 오류가 발생했습니다.
</h1>
<!-- Error Description -->
<p class="error-description">
서비스 이용에 불편을 드려 죄송합니다.<br>
빠른 서비스를 제공할 수 있게 준비하겠습니다.
<p class="error-description" th:text="${errorDescription ?: '잠시 후 다시 시도해 주세요.'}">
잠시 후 다시 시도해 주세요.
</p>
<!-- Error Message (서버에서 전달된 에러 메시지가 있을 경우 표시) -->
@@ -6,6 +6,10 @@
<meta content="https://www.eactive.co.kr/" property="og:url"/>
<meta charset="UTF-8"/>
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
@@ -278,8 +278,15 @@
}
function csrfHeaders() {
var token = getCookie('XSRF-TOKEN');
return token ? { 'X-XSRF-TOKEN': token } : {};
// 세션 기반 CSRF: 쿠키 대신 <meta>에서 토큰·헤더명을 읽는다.
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) {