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: