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 @@ + + +