diff --git a/backend/src/main/java/com/itcenter/acs/config/CsrfCookieFilter.java b/backend/src/main/java/com/itcenter/acs/config/CsrfCookieFilter.java new file mode 100644 index 0000000..5058235 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/config/CsrfCookieFilter.java @@ -0,0 +1,32 @@ +package com.itcenter.acs.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Forces the {@code XSRF-TOKEN} cookie to be rendered on responses. + *

+ * {@link org.springframework.security.web.csrf.CookieCsrfTokenRepository} only writes + * the cookie when the token is actually resolved. Calling {@link CsrfToken#getToken()} + * here guarantees the SPA receives the cookie (e.g. on the initial {@code GET /api/auth/me} + * probe or a public pass page load) so it can echo it back in the {@code X-XSRF-TOKEN} header. + */ +public class CsrfCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken != null) { + // Resolving the token value triggers CookieCsrfTokenRepository to set the cookie. + csrfToken.getToken(); + } + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/itcenter/acs/config/SecurityConfig.java b/backend/src/main/java/com/itcenter/acs/config/SecurityConfig.java index caa0acf..f0e7d91 100644 --- a/backend/src/main/java/com/itcenter/acs/config/SecurityConfig.java +++ b/backend/src/main/java/com/itcenter/acs/config/SecurityConfig.java @@ -3,19 +3,22 @@ package com.itcenter.acs.config; import com.itcenter.acs.dto.ApiResponse; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -31,6 +34,14 @@ public class SecurityConfig { private final ObjectMapper objectMapper = new ObjectMapper(); + /** Only expose the H2 console (and relax frame options for it) when it is actually enabled — dev only. */ + @Value("${spring.h2.console.enabled:false}") + private boolean h2ConsoleEnabled; + + /** Allowed CORS origins; empty in prod (nginx same-origin), localhost in dev. */ + @Value("${acs.cors.allowed-origins:http://localhost:5173,http://127.0.0.1:5173}") + private List allowedOrigins; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -48,29 +59,52 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // SPA double-submit: the SPA reads the XSRF-TOKEN cookie and echoes it in the + // X-XSRF-TOKEN header. The plain handler (XOR opt-out) makes the header value + // match the raw cookie token. + CsrfTokenRequestAttributeHandler csrfHandler = new CsrfTokenRequestAttributeHandler(); + csrfHandler.setCsrfRequestAttributeName(null); + http .cors(cors -> {}) - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(authz -> authz - // public - .requestMatchers("/api/auth/login", "/api/auth/logout").permitAll() - .requestMatchers("/", "/health", "/error").permitAll() - .requestMatchers("/h2-console/**").permitAll() - // public visitor pass (opened from the SMS link, token-guarded) - .requestMatchers("/api/public/**").permitAll() - // staff access console (search + force check-in/out) — 담당자(HOST)/보안/관리자 - .requestMatchers("/api/access/**").hasAnyRole("HOST", "SECURITY", "ADMIN") - // approvals — IT센터 관리자(admin) 전용 - .requestMatchers("/api/approvals/**").hasRole("ADMIN") - // blacklist & admin management — admin only - .requestMatchers("/api/blacklist/**", "/api/admin/**").hasRole("ADMIN") - // reports — security & admin - .requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN") - // everything else requires a logged-in user - .anyRequest().authenticated() + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(csrfHandler) + // login happens before a session exists; public pass endpoints are + // guarded by an unguessable token — both are exempt from CSRF. + .ignoringRequestMatchers("/api/auth/login", "/api/public/**") ) - // H2 console renders in a frame - .headers(h -> h.frameOptions(f -> f.sameOrigin())) + // ensure the XSRF-TOKEN cookie is written on every response + .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class) + .authorizeHttpRequests(authz -> { + authz + // public + .requestMatchers("/api/auth/login", "/api/auth/logout").permitAll() + .requestMatchers("/", "/health", "/error").permitAll(); + // H2 console — dev only (see h2ConsoleEnabled) + if (h2ConsoleEnabled) { + authz.requestMatchers("/h2-console/**").permitAll(); + } + authz + // public visitor pass (opened from the SMS link, token-guarded) + .requestMatchers("/api/public/**").permitAll() + // staff access console (search + force check-in/out) — 담당자(HOST)/보안/관리자 + .requestMatchers("/api/access/**").hasAnyRole("HOST", "SECURITY", "ADMIN") + // approvals — IT센터 관리자(admin) 전용 + .requestMatchers("/api/approvals/**").hasRole("ADMIN") + // blacklist & admin management — admin only + .requestMatchers("/api/blacklist/**", "/api/admin/**").hasRole("ADMIN") + // reports — security & admin + .requestMatchers("/api/reports/**").hasAnyRole("SECURITY", "ADMIN") + // everything else requires a logged-in user + .anyRequest().authenticated(); + }) + // relax frame options only for the H2 console (dev); prod keeps the default DENY + .headers(h -> { + if (h2ConsoleEnabled) { + h.frameOptions(f -> f.sameOrigin()); + } + }) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .securityContext(sc -> sc.securityContextRepository(securityContextRepository())) .exceptionHandling(ex -> ex @@ -91,10 +125,8 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of( - "http://localhost:5173", - "http://127.0.0.1:5173" - )); + // allowCredentials=true forbids "*" — origins must be listed explicitly (acs.cors.allowed-origins). + configuration.setAllowedOrigins(allowedOrigins); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties index b6c6520..5df2b6e 100644 --- a/backend/src/main/resources/application-prod.properties +++ b/backend/src/main/resources/application-prod.properties @@ -1,6 +1,19 @@ spring.application.name=acs server.port=8080 +# ===== Session cookie hardening ===== +# SameSite=Lax complements the CSRF token defense. +# Secure=true means the cookie is only sent over HTTPS — enable it (ACS_COOKIE_SECURE=true) +# ONCE the site is served over HTTPS, otherwise login breaks over plain http://internal-ip. +server.servlet.session.cookie.same-site=lax +server.servlet.session.cookie.http-only=true +server.servlet.session.cookie.secure=${ACS_COOKIE_SECURE:false} + +# ===== CORS ===== +# nginx serves web+API from the same origin, so CORS is normally not exercised (empty is fine). +# Set ACS_CORS_ALLOWED_ORIGINS (comma-separated) only if the SPA is hosted on a different origin. +acs.cors.allowed-origins=${ACS_CORS_ALLOWED_ORIGINS:} + # ===== PostgreSQL (prod) ===== spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:acs} spring.datasource.driver-class-name=org.postgresql.Driver diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index f5a2241..235505b 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,6 +1,11 @@ spring.application.name=acs server.port=8080 +# ===== Session cookie hardening ===== +# SameSite=Lax complements the CSRF token defense. Secure=false in dev (plain http). +server.servlet.session.cookie.same-site=lax +server.servlet.session.cookie.http-only=true + # ===== Logging ===== logging.level.root=INFO logging.level.com.itcenter.acs=DEBUG diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f2fef9f..b81bc72 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -21,10 +21,22 @@ const BASE = '/api'; /** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */ const NO_REDIRECT_ON_401 = ['/auth/me', '/auth/login', '/auth/logout', '/public/']; +/** + * CSRF double-submit: the backend sets an XSRF-TOKEN cookie; echo it back in the + * X-XSRF-TOKEN header on state-changing requests. GET/HEAD need no token. + */ +function csrfHeaders(method?: string): Record { + const m = (method ?? 'GET').toUpperCase(); + if (m === 'GET' || m === 'HEAD') return {}; + const c = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/); + return c ? { 'X-XSRF-TOKEN': decodeURIComponent(c[1]) } : {}; +} + async function request(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init, + headers: { ...(init?.headers ?? {}), ...csrfHeaders(init?.method) }, }); // Session expired / not authenticated on a protected call → send to login. if ( diff --git a/infra/.env.example b/infra/.env.example index 0646f5d..b8a4a63 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -16,3 +16,11 @@ ACS_SMS_API_URL=http://210.104.132.59:8000 # URL the SMS link points to — MUST be reachable from the visitor's phone # (the server's real address/domain, not localhost). e.g. https://acs.example.co.kr ACS_PUBLIC_BASE_URL=http://localhost + +# ===== Security ===== +# CORS allowed origins (comma-separated). Leave EMPTY when web+API share one origin +# via nginx (default). Set only if the SPA is hosted on a different origin. +ACS_CORS_ALLOWED_ORIGINS= +# Set to true ONLY when the site is served over HTTPS — marks the session cookie Secure. +# Leaving it false over plain http keeps login working; true over http would break it. +ACS_COOKIE_SECURE=false diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 24be928..453d8eb 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -28,6 +28,9 @@ services: ACS_SMS_PROVIDER: ${ACS_SMS_PROVIDER:-dev} ACS_SMS_API_URL: ${ACS_SMS_API_URL:-http://210.104.132.59:8000} ACS_PUBLIC_BASE_URL: ${ACS_PUBLIC_BASE_URL:-http://localhost} + # Security: CORS origins (empty = same-origin via nginx); cookie Secure (enable under HTTPS) + ACS_CORS_ALLOWED_ORIGINS: ${ACS_CORS_ALLOWED_ORIGINS:-} + ACS_COOKIE_SECURE: ${ACS_COOKIE_SECURE:-false} depends_on: - db networks: