- CSRF 토큰 저장소 쿠키 → 세션 전환 및 클라이언트 로직 수정
- error 페이지 텍스트 동적 처리 추가 (prod 환경 구분) - CSRF 메타 데이터 추가 및 JS/AJAX CSRF 로직 일원화
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user