Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 }}
10 changes: 9 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
5 changes: 5 additions & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(); // 브라우저 호환성
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
Expand All @@ -49,15 +52,7 @@ public ResponseEntity<KakaoLoginResponseDTO> 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())
Expand Down Expand Up @@ -116,14 +111,7 @@ public ResponseEntity<TokenRefreshResponse> 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())
Expand Down Expand Up @@ -244,4 +232,22 @@ public BaseResponse<List<GetAllAddressResponseDTO>> getAllAddress() {
List<GetAllAddressResponseDTO> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final RestAuthenticationEntryPoint restEntryPoint;
private final WebProperties webProperties;


@Bean
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/umc/cockple/demo/global/config/WebProperties.java
Original file line number Diff line number Diff line change
@@ -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<String> allowedOrigins;

private String cookieDomain;
}
6 changes: 6 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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");
}
}
3 changes: 3 additions & 0 deletions src/test/resources/application-integrationtest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ cockple:
object-storage-delete-outbox:
scheduler:
enabled: false
web:
allowed-origins: http://localhost:5173
cookie-domain: ""
2 changes: 1 addition & 1 deletion terraform/storage.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading