diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index ae1712d65..b9e12e315 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -73,7 +73,8 @@ jobs: DB_PASSWORD,GCS_BUCKET,GCS_BACKUP_BUCKET, KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,KAKAO_REDIRECT_URI_PROD,KAKAO_REDIRECT_URI_STAGING,KAKAO_ADMIN_KEY, JWT_SECRET_KEY,FIREBASE_SERVICE_ACCOUNT_KEY,FCM_FAKE_LATENCY_MS, - OTLP_METRICS_ENABLED,OTEL_URL,OTEL_AUTH,OTLP_STEP + OTLP_METRICS_ENABLED,OTEL_URL,OTEL_AUTH,OTLP_STEP, + CORS_ALLOWED_ORIGINS_PROD,CORS_ALLOWED_ORIGINS_STAGING,COOKIE_DOMAIN,NGINX_PROD_SERVER_NAME,NGINX_STAGING_SERVER_NAME script: | chmod +x /home/ubuntu/cockple/scripts/deploy.sh bash /home/ubuntu/cockple/scripts/deploy.sh \ @@ -96,3 +97,9 @@ jobs: OTEL_URL: ${{ vars.OTEL_URL }} OTEL_AUTH: ${{ secrets.OTEL_AUTH }} OTLP_STEP: ${{ vars.OTLP_STEP }} + # 도메인(SSOT) — 비밀 아님 → Variables. 미설정 시 빈 값 → docker-compose 기본값(현재 도메인) 사용(no-op). + CORS_ALLOWED_ORIGINS_PROD: ${{ vars.CORS_ALLOWED_ORIGINS_PROD }} + CORS_ALLOWED_ORIGINS_STAGING: ${{ vars.CORS_ALLOWED_ORIGINS_STAGING }} + COOKIE_DOMAIN: ${{ vars.COOKIE_DOMAIN }} + NGINX_PROD_SERVER_NAME: ${{ vars.NGINX_PROD_SERVER_NAME }} + NGINX_STAGING_SERVER_NAME: ${{ vars.NGINX_STAGING_SERVER_NAME }} diff --git a/docker-compose.yml b/docker-compose.yml index 637838ed0..2c46d8315 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,8 @@ services: JAVA_TOOL_OPTIONS: "-Xms768m -Xmx768m" SPRING_PROFILES_ACTIVE: prod LOG_PATH: /logs + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS_PROD:-http://localhost:5173,https://cockple.store,https://www.cockple.store,https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.cockple.store} DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} @@ -83,6 +85,8 @@ services: JAVA_TOOL_OPTIONS: "-Xms128m -Xmx512m" SPRING_PROFILES_ACTIVE: staging LOG_PATH: /logs + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS_STAGING:-http://localhost:5173,https://cockple.store,https://www.cockple.store,https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.cockple.store} FCM_FAKE_LATENCY_MS: ${FCM_FAKE_LATENCY_MS:-0} # 관측성(Observability) — OTLP 메트릭 push (staging 전용). 미설정 시 off. OTLP_METRICS_ENABLED: ${OTLP_METRICS_ENABLED:-false} @@ -113,9 +117,13 @@ services: restart: always ports: - "80:80" + environment: + PROD_SERVER_NAME: ${NGINX_PROD_SERVER_NAME:-api.cockple.store} + STAGING_SERVER_NAME: ${NGINX_STAGING_SERVER_NAME:-staging.cockple.store} + NGINX_ENVSUBST_FILTER: SERVER_NAME volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/templates:/etc/nginx/templates:ro mem_limit: 64m memswap_limit: 128m diff --git a/nginx/conf.d/prod.conf b/nginx/templates/prod.conf.template similarity index 95% rename from nginx/conf.d/prod.conf rename to nginx/templates/prod.conf.template index 635b2a3cf..867822e36 100644 --- a/nginx/conf.d/prod.conf +++ b/nginx/templates/prod.conf.template @@ -1,6 +1,6 @@ server { listen 80; - server_name api.cockple.store; + server_name ${PROD_SERVER_NAME}; location /ws/ { proxy_pass http://cockple-app:8080; diff --git a/nginx/conf.d/staging.conf b/nginx/templates/staging.conf.template similarity index 95% rename from nginx/conf.d/staging.conf rename to nginx/templates/staging.conf.template index dc016b844..0935e2557 100644 --- a/nginx/conf.d/staging.conf +++ b/nginx/templates/staging.conf.template @@ -1,6 +1,6 @@ server { listen 80; - server_name staging.cockple.store; + server_name ${STAGING_SERVER_NAME}; location /ws/ { proxy_pass http://cockple-app-staging:8080; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index eeb16e2b1..d627e76be 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -31,6 +31,11 @@ OTLP_METRICS_ENABLED=${OTLP_METRICS_ENABLED:-false} OTEL_URL=${OTEL_URL:-} OTEL_AUTH=${OTEL_AUTH:-} OTLP_STEP=${OTLP_STEP:-1m} +CORS_ALLOWED_ORIGINS_PROD=${CORS_ALLOWED_ORIGINS_PROD:-} +CORS_ALLOWED_ORIGINS_STAGING=${CORS_ALLOWED_ORIGINS_STAGING:-} +COOKIE_DOMAIN=${COOKIE_DOMAIN:-} +NGINX_PROD_SERVER_NAME=${NGINX_PROD_SERVER_NAME:-} +NGINX_STAGING_SERVER_NAME=${NGINX_STAGING_SERVER_NAME:-} EOF echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > /home/ubuntu/cockple/firebase-service-account.json diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketConfig.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketConfig.java index 221d884dc..a2b426378 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketConfig.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketConfig.java @@ -5,6 +5,7 @@ import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import umc.cockple.demo.global.config.WebProperties; @Configuration @EnableWebSocket @@ -13,13 +14,14 @@ public class ChatWebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatWebSocketHandler; private final JWTWebSocketAuthInterceptor jwtWebSocketAuthInterceptor; + private final WebProperties webProperties; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry .addHandler(chatWebSocketHandler, "/ws/chats") .addInterceptors(jwtWebSocketAuthInterceptor) - .setAllowedOrigins("http://localhost:5173", "https://cockple.store", "https://www.cockple.store", "https://cockple-fe.vercel.app/", "https://staging.cockple.store") + .setAllowedOrigins(webProperties.getAllowedOrigins().toArray(new String[0])) .withSockJS(); // 브라우저 호환성 } } diff --git a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java index 2c9162f54..ea373651c 100644 --- a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java +++ b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java @@ -16,6 +16,7 @@ import umc.cockple.demo.domain.member.service.MemberCommandService; import umc.cockple.demo.domain.member.service.MemberProfileUpdateExecutor; import umc.cockple.demo.domain.member.service.MemberQueryService; +import umc.cockple.demo.global.config.WebProperties; import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; import umc.cockple.demo.global.oauth2.service.KakaoOauthService; import umc.cockple.demo.global.response.BaseResponse; @@ -41,6 +42,8 @@ public class MemberController { private final MemberProfileUpdateExecutor memberProfileUpdateExecutor; private final MemberQueryService memberQueryService; private final KakaoOauthService kakaoOauthService; + private final WebProperties webProperties; + @PostMapping("/oauth/login") @Operation(summary = "카카오 소셜 로그인 API", @@ -49,15 +52,7 @@ public ResponseEntity login(@RequestBody @Valid KakaoLogi KakaoLoginResponseDTO response = kakaoOauthService.signup(requestDTO.code()); - ResponseCookie cookie = ResponseCookie.from("refreshToken", response.refreshToken()) - .httpOnly(true) - .secure(true) - .path("/") - .maxAge(Duration.ofDays(7)) - .sameSite("None") - .domain(".cockple.store") - .build() - ; + ResponseCookie cookie = buildRefreshTokenCookie(response.refreshToken()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) @@ -116,14 +111,7 @@ public ResponseEntity refresh(@CookieValue("refreshToken") // 리프레시 토큰 유효성 검사 TokenRefreshResponse response = kakaoOauthService.validateMember(refreshToken); - ResponseCookie cookie = ResponseCookie.from("refreshToken", response.refreshToken()) - .httpOnly(true) - .secure(true) - .path("/") - .maxAge(Duration.ofDays(7)) - .sameSite("None") - .domain(".cockple.store") - .build(); + ResponseCookie cookie = buildRefreshTokenCookie(response.refreshToken()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) @@ -244,4 +232,22 @@ public BaseResponse> getAllAddress() { List addresses = memberQueryService.getAllAddress(memberId); return BaseResponse.success(CommonSuccessCode.OK, addresses); } + + /** + * refreshToken 쿠키 생성(로그인/재발급 공통) + */ + private ResponseCookie buildRefreshTokenCookie(String refreshToken) { + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(Duration.ofDays(7)) + .sameSite("None"); + + String cookieDomain = webProperties.getCookieDomain(); + if (cookieDomain != null && !cookieDomain.isBlank()) { + builder.domain(cookieDomain); + } + return builder.build(); + } } diff --git a/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java b/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java index d6b0a6579..61b2c3179 100644 --- a/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java @@ -25,6 +25,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final RestAuthenticationEntryPoint restEntryPoint; + private final WebProperties webProperties; @Bean @@ -66,7 +67,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:5173", "https://cockple.store", "https://www.cockple.store", "https://staging.cockple.store", "https://cockple-fe.vercel.app")); // 배포 시에는 도메인 지정 권장 + // 허용 Origin은 WebProperties(SSOT)에서 주입. 정확 매칭 유지 — allowCredentials(true)와 + // origin 패턴/와일드카드를 함께 쓰면 요청 Origin이 반사되어 심각한 취약점이 되므로 절대 금지. + config.setAllowedOrigins(webProperties.getAllowedOrigins()); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); diff --git a/src/main/java/umc/cockple/demo/global/config/WebProperties.java b/src/main/java/umc/cockple/demo/global/config/WebProperties.java new file mode 100644 index 000000000..3b1ce7634 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/config/WebProperties.java @@ -0,0 +1,27 @@ +package umc.cockple.demo.global.config; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +/** + * 도메인/오리진 관련 웹 설정의 단일 소스(SSOT) + * 값은 application.yml의 기본값 또는 환경변수(CORS_ALLOWED_ORIGINS, COOKIE_DOMAIN)로 주입 + */ +@Getter +@Setter +@Configuration +@Validated +@ConfigurationProperties(prefix = "cockple.web") +public class WebProperties { + + @NotEmpty(message = "cockple.web.allowed-origins must not be empty") + private List allowedOrigins; + + private String cookieDomain; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6120c9375..fccd0b611 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -71,6 +71,12 @@ spring: gcs: bucket: ${GCS_BUCKET} +cockple: + web: + # WS도 이 목록 공유. cockple-fe.vercel.app/(끝슬래시)는 WS 기존값 유지용(무해). + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,https://cockple.store,https://www.cockple.store,https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/} + cookie-domain: ${COOKIE_DOMAIN:.cockple.store} + kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} diff --git a/src/test/java/umc/cockple/demo/global/config/WebPropertiesTest.java b/src/test/java/umc/cockple/demo/global/config/WebPropertiesTest.java new file mode 100644 index 000000000..e460752a5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/config/WebPropertiesTest.java @@ -0,0 +1,73 @@ +package umc.cockple.demo.global.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * WebProperties 바인딩 검증. + * 도메인 값을 하드코딩에서 설정 바인딩으로 옮긴 뒤에도, 운영과 "동일한" origin 목록/쿠키 도메인이 + * 실제로 그대로 풀리는지(no-op) 배포 없이 결정적으로 확인한다. + */ +class WebPropertiesTest { + + private WebProperties bind(Map props) { + Binder binder = new Binder(new MapConfigurationPropertySource(props)); + return binder.bind("cockple.web", WebProperties.class).get(); + } + + @Test + @DisplayName("application.yml 기본값(콤마 구분)이 운영과 동일한 origin 목록으로 바인딩된다") + void bindsCommaSeparatedOriginsExactlyAsBefore() { + // application.yml의 CORS_ALLOWED_ORIGINS 기본값과 동일한 문자열 + // (WS 편입으로 CORS/WS가 공유. cockple-fe.vercel.app/ 는 WS 기존값 유지용) + String defaultOrigins = + "http://localhost:5173,https://cockple.store,https://www.cockple.store," + + "https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/"; + + WebProperties props = bind(Map.of( + "cockple.web.allowed-origins", defaultOrigins, + "cockple.web.cookie-domain", ".cockple.store" + )); + + // 기존 CORS + WS 하드코딩 origin을 모두 포함해야 한다(순수 no-op) + assertThat(props.getAllowedOrigins()).containsExactly( + "http://localhost:5173", + "https://cockple.store", + "https://www.cockple.store", + "https://staging.cockple.store", + "https://cockple-fe.vercel.app", + "https://cockple-fe.vercel.app/" + ); + assertThat(props.getCookieDomain()).isEqualTo(".cockple.store"); + } + + @Test + @DisplayName("cookie-domain이 비면 null/blank로 바인딩되어 host-only 쿠키로 동작한다") + void blankCookieDomainMeansHostOnly() { + WebProperties props = bind(Map.of( + "cockple.web.allowed-origins", "http://localhost:5173", + "cockple.web.cookie-domain", "" + )); + + String cookieDomain = props.getCookieDomain(); + assertThat(cookieDomain == null || cookieDomain.isBlank()).isTrue(); + } + + @Test + @DisplayName("env로 새 도메인을 주입하면 코드 변경 없이 목록이 교체된다") + void envOverrideReplacesOrigins() { + WebProperties props = bind(Map.of( + "cockple.web.allowed-origins", "https://new-domain.com,https://api.new-domain.com" + )); + + assertThat(props.getAllowedOrigins()) + .containsExactly("https://new-domain.com", "https://api.new-domain.com"); + } +} diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml index 07569887b..e75889e10 100644 --- a/src/test/resources/application-integrationtest.yml +++ b/src/test/resources/application-integrationtest.yml @@ -33,6 +33,9 @@ cockple: object-storage-delete-outbox: scheduler: enabled: false + web: + allowed-origins: http://localhost:5173 + cookie-domain: "" kakao: client-id: test-client-id diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index e055d686f..3741cc941 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -30,3 +30,6 @@ cockple: object-storage-delete-outbox: scheduler: enabled: false + web: + allowed-origins: http://localhost:5173 + cookie-domain: "" diff --git a/terraform/storage.tf b/terraform/storage.tf index 333b02378..dd0e9bf75 100644 --- a/terraform/storage.tf +++ b/terraform/storage.tf @@ -16,7 +16,7 @@ resource "google_storage_bucket" "cockple_assets" { uniform_bucket_level_access = true cors { - origin = ["https://cockple.store", "https://staging.cockple.store"] + origin = ["https://${var.root_domain}", "https://staging.${var.root_domain}"] method = ["GET", "PUT", "POST", "DELETE"] response_header = ["Content-Type"] max_age_seconds = 3600 diff --git a/terraform/variables.tf b/terraform/variables.tf index 1d549e13f..95970edc5 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -16,10 +16,16 @@ variable "cloudflare_api_token" { } variable "cloudflare_zone_id" { - description = "cockple.store Cloudflare Zone ID" + description = "루트 도메인(root_domain)의 Cloudflare Zone ID" type = string } +variable "root_domain" { + description = "서비스 루트 도메인. 도메인 변경 시 이 값(+ cloudflare_zone_id)만 교체." + type = string + default = "cockple.store" +} + variable "ssh_public_key" { description = "인스턴스 접속용 SSH 공개키" type = string