From 82948a13ce4c2a2efc24669b66c501aabdd5fda4 Mon Sep 17 00:00:00 2001
From: Rinjae
Date: Fri, 26 Jun 2026 15:05:19 +0900
Subject: [PATCH] =?UTF-8?q?CSRF=20=EB=A7=8C=EB=A3=8C=20=EC=84=B8=EC=85=98?=
=?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=A0=95=EC=A0=81=20?=
=?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=BA=90=EC=8B=B1=20=EC=A0=9C?=
=?UTF-8?q?=EC=96=B4=20=EC=B6=94=EA=B0=80=20-=20CSRF=20=EB=A7=8C=EB=A3=8C?=
=?UTF-8?q?=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EC=84=B8=EC=85=98=20?=
=?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=95=88=EB=82=B4=EB=A1=9C=20=EB=A6=AC?=
=?UTF-8?q?=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20-?=
=?UTF-8?q?=20=EC=A0=95=EC=A0=81=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=BA=90?=
=?UTF-8?q?=EC=8B=B1=20=ED=86=A0=EA=B8=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80=20(=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20?=
=?UTF-8?q?=EC=A6=89=EC=8B=9C=20=EB=B0=98=EC=98=81=20=EC=A7=80=EC=9B=90)?=
=?UTF-8?q?=20-=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=8E=98?=
=?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20=EC=84=B8=EC=85=98=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../portal/config/PortalConfigSecurity.java | 25 ++++++++++++++++
.../PortalConfigWebDispatcherServlet.java | 29 +++++++++++++++++--
src/main/resources/application-prod.yml | 8 ++++-
src/main/resources/application.yml | 11 +++++--
src/main/resources/static/css/main.css | 2 +-
.../static/sass/pages/_signup-selection.scss | 2 +-
.../templates/views/fragment/djbank/head.html | 6 ++++
7 files changed, 76 insertions(+), 7 deletions(-)
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 d878ede..8011c89 100644
--- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java
+++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java
@@ -12,7 +12,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -107,6 +110,11 @@ public class PortalConfigSecurity {
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
)
+ // 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
+ // 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
+ // 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
+ // CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true)로 보낸다.
+ .exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
@@ -121,6 +129,23 @@ public class PortalConfigSecurity {
}
}
+ /**
+ * CSRF 토큰 누락/불일치는 "권한없음"이 아니라 (대개) 세션 만료다.
+ * 로그인 페이지에 오래 머물러 세션 내 CSRF 토큰이 사라진 경우가 대표적이므로
+ * 기존 세션 만료 안내(login.html의 {@code param.expired})를 재사용해 오해를 막는다.
+ * 그 외 진짜 권한 위반(@Secured 등)은 기본 핸들러에 위임해 403을 유지한다.
+ */
+ private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
+ AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
+ return (request, response, accessDeniedException) -> {
+ if (accessDeniedException instanceof CsrfException) {
+ response.sendRedirect(request.getContextPath() + "/login?expired=true");
+ return;
+ }
+ delegate.handle(request, response, accessDeniedException);
+ };
+ }
+
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java
index f224fb2..6084994 100644
--- a/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java
+++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java
@@ -45,6 +45,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Value("${app.resource-versioning.enabled:true}")
private boolean resourceVersioningEnabled;
+ // 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
+ // prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
+ // 개발환경에서 OFF 면 sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
+ @Value("${app.resource-caching.enabled:false}")
+ private boolean resourceCachingEnabled;
+
public PortalConfigWebDispatcherServlet(Environment environment) {
this.environment = environment;
}
@@ -97,10 +103,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
+ // 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
+ // no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
+ CacheControl cacheControl = isResourceCachingEnabled()
+ ? CacheControl.maxAge(1, TimeUnit.DAYS)
+ : CacheControl.noStore();
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
- .setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS))
- .resourceChain(true);
+ .setCacheControl(cacheControl)
+ .resourceChain(isResourceCachingEnabled());
if (isResourceVersioningEnabled()) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
@@ -118,6 +129,20 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
return resourceVersioningEnabled;
}
+ /**
+ * 정적자원 서버측 캐싱(ResourceChain 의 CachingResourceResolver) 활성 여부.
+ * prod 프로파일은 토글과 무관하게 항상 ON, 그 외 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
+ *
+ * OFF 면 매 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
+ * ({@code spring.web.resources.cache} 는 브라우저 캐시 헤더만 제어할 뿐 이 서버측 캐시와는 무관하다.)
+ */
+ private boolean isResourceCachingEnabled() {
+ if (environment.acceptsProfiles(Profiles.of("prod"))) {
+ return true;
+ }
+ return resourceCachingEnabled;
+ }
+
@Bean
public MappingJackson2JsonView jsonView() {
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 4fd952a..6c9a52d 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -29,4 +29,10 @@ spring:
cache: false
portal:
- test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
\ No newline at end of file
+ test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
+
+app:
+ resource-versioning:
+ enabled: true
+ resource-caching:
+ enabled: true
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 4d04d38..6e48ae5 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -2,7 +2,9 @@ server:
servlet:
context-path: /
session:
- timeout: 15m
+ timeout: 10m
+ cookie:
+ name: PORTAL_JSESSIONID
encoding:
charset: UTF-8
port: '39130'
@@ -59,7 +61,12 @@ spring:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
app:
resource-versioning:
- enabled: true
+ enabled: false
+ # 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
+ # false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
+ # prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
+ resource-caching:
+ enabled: false
security:
basic:
diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css
index 45057ed..14fd321 100644
--- a/src/main/resources/static/css/main.css
+++ b/src/main/resources/static/css/main.css
@@ -10908,7 +10908,7 @@ button.djb-comment-submit:disabled {
}
}
.signup-selection-page {
- min-height: calc(100vh - 140px);
+ min-height: calc(100vh - 380px);
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/main/resources/static/sass/pages/_signup-selection.scss b/src/main/resources/static/sass/pages/_signup-selection.scss
index 4ea9161..9518858 100644
--- a/src/main/resources/static/sass/pages/_signup-selection.scss
+++ b/src/main/resources/static/sass/pages/_signup-selection.scss
@@ -7,7 +7,7 @@
// -----------------------------------------------------------------------------
.signup-selection-page {
- min-height: calc(100vh - 140px); // Account for header and footer
+ min-height: calc(100vh - 380px); // Account for header and footer
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/main/resources/templates/views/fragment/djbank/head.html b/src/main/resources/templates/views/fragment/djbank/head.html
index bf91cc8..9b1e1d1 100644
--- a/src/main/resources/templates/views/fragment/djbank/head.html
+++ b/src/main/resources/templates/views/fragment/djbank/head.html
@@ -82,6 +82,12 @@
+
+
+