From d3b869dbd4dd692f1103d9eddf534fd8905096f0 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Mon, 8 Dec 2025 07:30:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20auth=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EB=A1=9C=EB=B6=80=ED=84=B0=20=EC=9D=B4?= =?UTF-8?q?=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.local | 59 +++++++ Dockerfile | 81 ++++++++- build.gradle.kts | 6 +- docker-compose-dev.yaml | 127 ++++++++++++-- docker-compose-local.yml | 115 +++++++++++++ .../eternity/dgs/config/JwtProperties.java | 15 ++ .../dgs/filter/JwtAuthenticationFilter.java | 155 ++++++++++++++++++ ...JwtAuthenticationGatewayFilterFactory.java | 75 --------- .../dgs/filter/UserContextFilter.java | 53 ++++++ .../eternity/dgs/util/JwtTokenProvider.java | 119 ++++++++++++++ 10 files changed, 707 insertions(+), 98 deletions(-) create mode 100644 .env.local create mode 100644 docker-compose-local.yml create mode 100644 src/main/java/until/the/eternity/dgs/config/JwtProperties.java create mode 100644 src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationFilter.java delete mode 100644 src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java create mode 100644 src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java create mode 100644 src/main/java/until/the/eternity/dgs/util/JwtTokenProvider.java diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..305605a --- /dev/null +++ b/.env.local @@ -0,0 +1,59 @@ +# =================================== +# DevNogi Gateway Server - Local Environment Configuration +# =================================== +# 이 파일은 로컬 개발 환경용 설정입니다. +# 사용법: docker-compose -f docker-compose-local.yml --env-file .env.local up --build + +# === Application Configuration === +SPRING_PROFILES_ACTIVE=local +SERVER_PORT=8099 + +# === Downstream Service URLs === +# 로컬 개발 시 호스트의 다른 서비스에 연결 (Docker 외부) +AUTH_SERVER_URL=http://host.docker.internal:8091 +OPEN_API_BATCH_SERVER_URL=http://host.docker.internal:8092 +COMMUNITY_SERVER_URL=http://host.docker.internal:8093 + +# === Security Configuration === +JWT_SECRET_KEY=e4f1a5c8d2b7e9f0a6c3d1b8e5f2c7a9d4e6f3b1a2c8d5e9f0b3a7c2d1e8f5a4 +JWT_ISSUER=devnogi + +# === CORS Configuration === +CORS_ALLOWED_ORIGINS=* + +# === JVM Configuration (로컬 개발용 - 메모리 사용량 감소) === +JAVA_OPTS_XMS=128m +JAVA_OPTS_XMX=256m +JAVA_OPTS_MAX_METASPACE_SIZE=128m +JAVA_OPTS_RESERVED_CODE_CACHE_SIZE=32m +JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE=32m +JAVA_OPTS_XSS=512k +JAVA_OPTS_MAX_GC_PAUSE_MILLIS=200 +JAVA_OPTS_G1_HEAP_REGION_SIZE=1m +JAVA_OPTS_INITIATING_HEAP_OCCUPANCY_PERCENT=45 +JAVA_OPTS_TIERED_STOP_AT_LEVEL=1 +JAVA_OPTS_CI_COMPILER_COUNT=2 + +# === Docker Resource Limits (로컬 개발용) === +DOCKER_MEMORY_LIMIT=512m +DOCKER_MEMORY_RESERVATION=256m + +# === Restart Policy === +RESTART_POLICY_MAX_RETRIES=3 + +# === Health Check Configuration === +HEALTHCHECK_INTERVAL=30s +HEALTHCHECK_TIMEOUT=10s +HEALTHCHECK_RETRIES=3 +HEALTHCHECK_START_PERIOD=60s + +# === Logging Configuration === +LOGGING_MAX_SIZE=10m +LOGGING_MAX_FILE=3 + +# === Autoheal Configuration === +AUTOHEAL_INTERVAL=30 +AUTOHEAL_START_PERIOD=0 +AUTOHEAL_DEFAULT_STOP_TIMEOUT=10 +AUTOHEAL_MEMORY_LIMIT=50m +AUTOHEAL_MEMORY_RESERVATION=20m diff --git a/Dockerfile b/Dockerfile index 256825c..ab424a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,76 @@ -# Dockerfile -FROM openjdk:21-jdk-slim -ARG JAR_FILE=build/libs/*.jar -COPY ${JAR_FILE} app.jar -ENTRYPOINT ["java", "-jar", "/app.jar"] +# Multi-Stage Dockerfile for Spring Cloud Gateway +# Stage 1: Build Stage - Gradle을 사용하여 애플리케이션 빌드 +# Stage 2: Extract Stage - Spring Boot Layered JAR 추출 +# Stage 3: Runtime Stage - 최종 런타임 이미지 + +# Stage 1: Build Stage +FROM gradle:8.5-jdk21 AS builder + +# 작업 디렉토리 설정 +WORKDIR /app + +# Gradle 의존성 다운로드를 위한 파일만 먼저 복사 (레이어 캐싱 최적화) +COPY gradle gradle +COPY gradlew . +COPY build.gradle.kts . +COPY settings.gradle.kts . + +# gradle.properties가 없으면 빈 파일 생성 +RUN touch gradle.properties 2>/dev/null || true + +# 의존성 다운로드 (캐시 활용) +RUN gradle dependencies --no-daemon || true + +# 소스 코드 복사 +COPY src src + +# 애플리케이션 빌드 (테스트 제외) +RUN gradle clean bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 이름 변경 +RUN mkdir -p /app/build/extracted && \ + cp /app/build/libs/*.jar /app/build/app.jar + +# Stage 2: Extract Layers +FROM eclipse-temurin:21-jre-alpine AS extractor + +WORKDIR /app + +# 빌드된 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# Spring Boot Layered JAR 추출 (레이어 최적화) +RUN java -Djarmode=layertools -jar app.jar extract + +# Stage 3: Final Runtime Stage +FROM eclipse-temurin:21-jre-alpine + +# 메타데이터 추가 +LABEL maintainer="DevNogi Team" +LABEL description="DevNogi Gateway Server - API Gateway with JWT Authentication" +LABEL version="0.0.1" + +# 보안: non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 작업 디렉토리 설정 +WORKDIR /app + +# 레이어별로 복사 (의존성 변경 시 캐시 활용) +COPY --from=extractor --chown=spring:spring /app/dependencies/ ./ +COPY --from=extractor --chown=spring:spring /app/spring-boot-loader/ ./ +COPY --from=extractor --chown=spring:spring /app/snapshot-dependencies/ ./ +COPY --from=extractor --chown=spring:spring /app/application/ ./ + +# 로그 디렉토리 생성 및 권한 설정 +RUN mkdir -p /app/logs /app/logs/archive && \ + chown -R spring:spring /app/logs + +# 사용자 전환 +USER spring:spring + +# JVM 메모리 설정 환경변수 (기본값, docker-compose에서 오버라이드) +ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200" + +# 애플리케이션 실행 (환경변수 JAVA_OPTS 사용) +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"] diff --git a/build.gradle.kts b/build.gradle.kts index e2fe958..11cd3d4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,9 +33,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webflux") // ✅ JWT 인증용 - implementation("io.jsonwebtoken:jjwt-api:0.11.5") - runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") - runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") + implementation("io.jsonwebtoken:jjwt-api:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5") // ✅ Actuator (모니터링 및 헬스 체크) implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index e8c0b8e..c21fd06 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -1,31 +1,128 @@ version: "3.8" services: - spring-app: - image: ${DOCKER_USERNAME}/${DOCKER_REPO}:latest - container_name: spring-app + gateway-app: + build: + context: . + dockerfile: Dockerfile + image: ${DOCKER_USERNAME}/${DOCKER_REPO}:${DOCKER_IMAGE_TAG:-latest} + container_name: gateway-app ports: - "${SERVER_PORT}:${SERVER_PORT}" env_file: - .env + labels: + # Autoheal: unhealthy 상태 시 자동 재시작 활성화 + autoheal: "true" + environment: + # === Application Configuration === + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + SERVER_PORT: ${SERVER_PORT} + + # === Downstream Service URLs === + AUTH_SERVER_URL: ${AUTH_SERVER_URL} + OPEN_API_BATCH_SERVER_URL: ${OPEN_API_BATCH_SERVER_URL} + COMMUNITY_SERVER_URL: ${COMMUNITY_SERVER_URL} + + # === Security Configuration === + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + JWT_ISSUER: ${JWT_ISSUER} + + # === CORS Configuration === + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + + # === Docker Configuration === + DOCKER_USERNAME: ${DOCKER_USERNAME} + DOCKER_REPO: ${DOCKER_REPO} + + # === JVM Configuration === + # All JVM options are now configurable via .env file + JAVA_OPTS: >- + -Xms${JAVA_OPTS_XMS} + -Xmx${JAVA_OPTS_XMX} + -XX:MaxMetaspaceSize=${JAVA_OPTS_MAX_METASPACE_SIZE} + -XX:ReservedCodeCacheSize=${JAVA_OPTS_RESERVED_CODE_CACHE_SIZE} + -XX:MaxDirectMemorySize=${JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE} + -Xss${JAVA_OPTS_XSS} + -XX:+UseG1GC + -XX:MaxGCPauseMillis=${JAVA_OPTS_MAX_GC_PAUSE_MILLIS} + -XX:G1HeapRegionSize=${JAVA_OPTS_G1_HEAP_REGION_SIZE} + -XX:InitiatingHeapOccupancyPercent=${JAVA_OPTS_INITIATING_HEAP_OCCUPANCY_PERCENT} + -XX:+TieredCompilation + -XX:TieredStopAtLevel=${JAVA_OPTS_TIERED_STOP_AT_LEVEL} + -XX:CICompilerCount=${JAVA_OPTS_CI_COMPILER_COUNT} + -XX:+UseCompressedOops + -XX:+UseCompressedClassPointers + -Djava.security.egd=file:/dev/./urandom + -Dspring.jmx.enabled=false volumes: - - ./logs:/app/logs - - ./config:/app/config:ro - restart: always + - gateway-logs:/app/logs # Named volume 사용 (권한 문제 해결) + # Restart Policy: + # - always: 항상 재시작 (수동 stop 포함) + # - unless-stopped: 수동 stop 제외하고 재시작 + # - on-failure:N: 실패 시 최대 N번만 재시작 (무한 재시작 루프 방지) + restart: on-failure:${RESTART_POLICY_MAX_RETRIES} + + # Docker Resource Limits (cgroup을 통한 강제 메모리 제한) + deploy: + resources: + limits: + memory: ${DOCKER_MEMORY_LIMIT} # 컨테이너 최대 메모리 (hard limit, OOM killer threshold) + reservations: + memory: ${DOCKER_MEMORY_RESERVATION} # 예약 메모리 (soft limit, guaranteed minimum) + networks: - - app-network + - gateway-network + # Health Check: 컨테이너 상태 감지 (autoheal과 연동) + # wget 사용 (Alpine Linux에 기본 설치되어 있음) healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:${SERVER_PORT}/actuator/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${SERVER_PORT}/actuator/health"] + interval: ${HEALTHCHECK_INTERVAL} # 체크 주기 + timeout: ${HEALTHCHECK_TIMEOUT} # 응답 타임아웃 + retries: ${HEALTHCHECK_RETRIES} # 연속 실패 횟수 + start_period: ${HEALTHCHECK_START_PERIOD} # 시작 유예 기간 logging: driver: "json-file" options: - max-size: "10m" - max-file: "3" + max-size: ${LOGGING_MAX_SIZE} # 로그 파일 최대 크기 + max-file: "${LOGGING_MAX_FILE}" # 로그 파일 보관 개수 + + # Autoheal: unhealthy 컨테이너 자동 재시작 서비스 + # - gateway-app이 unhealthy 상태가 되면 자동으로 재시작 + # - Docker 소켓을 마운트하여 컨테이너 관리 권한 획득 + # - healthcheck와 독립적으로 동작 (healthcheck가 unhealthy 판정하면 autoheal이 재시작) + autoheal: + image: willfarrell/autoheal:latest + container_name: autoheal-gateway + restart: unless-stopped + environment: + # AUTOHEAL_INTERVAL: 체크 주기 (초 단위) + AUTOHEAL_INTERVAL: ${AUTOHEAL_INTERVAL} # unhealthy 컨테이너 체크 주기 (healthcheck interval과 동기화 권장) + # AUTOHEAL_START_PERIOD: 컨테이너 시작 후 체크 시작까지 유예 시간 (초) + AUTOHEAL_START_PERIOD: ${AUTOHEAL_START_PERIOD} # healthcheck의 start_period를 따르므로 0으로 설정 + # AUTOHEAL_DEFAULT_STOP_TIMEOUT: 재시작 시 강제 종료까지 대기 시간 (초) + AUTOHEAL_DEFAULT_STOP_TIMEOUT: ${AUTOHEAL_DEFAULT_STOP_TIMEOUT} # graceful shutdown 대기 시간 + # DOCKER_SOCK: Docker 소켓 경로 (컨테이너 제어용) + DOCKER_SOCK: /var/run/docker.sock + volumes: + # Docker 소켓 마운트 (컨테이너 재시작 권한 획득) + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - gateway-network + # autoheal은 매우 가벼운 서비스 (메모리 ~10MB) + deploy: + resources: + limits: + memory: ${AUTOHEAL_MEMORY_LIMIT} + reservations: + memory: ${AUTOHEAL_MEMORY_RESERVATION} + +volumes: + gateway-logs: + driver: local networks: - app-network: + gateway-network: driver: bridge diff --git a/docker-compose-local.yml b/docker-compose-local.yml new file mode 100644 index 0000000..8ed528d --- /dev/null +++ b/docker-compose-local.yml @@ -0,0 +1,115 @@ +version: "3.8" + +# 로컬 개발 환경용 Docker Compose 설정 +# 사용법: docker-compose -f docker-compose-local.yml --env-file .env.local up --build +# 참고: 쉘 환경 변수가 .env.local 파일의 값을 오버라이드할 수 있으므로 --env-file 옵션을 명시하는 것이 좋습니다 + +services: + gateway-app: + build: + context: . + dockerfile: Dockerfile + # 로컬 이미지 이름 (Docker Hub에 push하지 않음) + image: devnogi-gateway-server:local + container_name: gateway-app-local + ports: + - "${SERVER_PORT:-8099}:${SERVER_PORT:-8099}" + env_file: + - .env.local # 로컬 환경 변수 파일 + labels: + autoheal: "true" + environment: + # === Application Configuration === + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + SERVER_PORT: ${SERVER_PORT:-8099} + + # === Downstream Service URLs (로컬 개발 시 호스트의 다른 서비스 연결) === + AUTH_SERVER_URL: ${AUTH_SERVER_URL:-http://host.docker.internal:8091} + OPEN_API_BATCH_SERVER_URL: ${OPEN_API_BATCH_SERVER_URL:-http://host.docker.internal:8092} + COMMUNITY_SERVER_URL: ${COMMUNITY_SERVER_URL:-http://host.docker.internal:8093} + + # === Security Configuration === + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-e4f1a5c8d2b7e9f0a6c3d1b8e5f2c7a9d4e6f3b1a2c8d5e9f0b3a7c2d1e8f5a4} + JWT_ISSUER: ${JWT_ISSUER:-devnogi} + + # === CORS Configuration === + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*} + + # === JVM Configuration (로컬 개발용 - 메모리 사용량 감소) === + JAVA_OPTS: >- + -Xms${JAVA_OPTS_XMS:-128m} + -Xmx${JAVA_OPTS_XMX:-256m} + -XX:MaxMetaspaceSize=${JAVA_OPTS_MAX_METASPACE_SIZE:-128m} + -XX:ReservedCodeCacheSize=${JAVA_OPTS_RESERVED_CODE_CACHE_SIZE:-32m} + -XX:MaxDirectMemorySize=${JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE:-32m} + -Xss${JAVA_OPTS_XSS:-512k} + -XX:+UseG1GC + -XX:MaxGCPauseMillis=${JAVA_OPTS_MAX_GC_PAUSE_MILLIS:-200} + -XX:G1HeapRegionSize=${JAVA_OPTS_G1_HEAP_REGION_SIZE:-1m} + -XX:InitiatingHeapOccupancyPercent=${JAVA_OPTS_INITIATING_HEAP_OCCUPANCY_PERCENT:-45} + -XX:+TieredCompilation + -XX:TieredStopAtLevel=${JAVA_OPTS_TIERED_STOP_AT_LEVEL:-1} + -XX:CICompilerCount=${JAVA_OPTS_CI_COMPILER_COUNT:-2} + -XX:+UseCompressedOops + -XX:+UseCompressedClassPointers + -Djava.security.egd=file:/dev/./urandom + -Dspring.jmx.enabled=false + volumes: + - gateway-logs:/app/logs # Named volume 사용 (권한 문제 해결) + restart: on-failure:${RESTART_POLICY_MAX_RETRIES:-3} + + # 로컬 개발용 리소스 제한 (더 적은 리소스) + deploy: + resources: + limits: + memory: ${DOCKER_MEMORY_LIMIT:-512m} + reservations: + memory: ${DOCKER_MEMORY_RESERVATION:-256m} + + networks: + - gateway-network + + # Health Check + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${SERVER_PORT:-8099}/actuator/health"] + interval: ${HEALTHCHECK_INTERVAL:-30s} + timeout: ${HEALTHCHECK_TIMEOUT:-10s} + retries: ${HEALTHCHECK_RETRIES:-3} + start_period: ${HEALTHCHECK_START_PERIOD:-60s} + + logging: + driver: "json-file" + options: + max-size: ${LOGGING_MAX_SIZE:-10m} + max-file: "${LOGGING_MAX_FILE:-3}" + + # Autoheal: unhealthy 컨테이너 자동 재시작 + autoheal: + image: willfarrell/autoheal:latest + container_name: autoheal-gateway-local + restart: unless-stopped + environment: + AUTOHEAL_INTERVAL: ${AUTOHEAL_INTERVAL:-30} + AUTOHEAL_START_PERIOD: ${AUTOHEAL_START_PERIOD:-0} + AUTOHEAL_DEFAULT_STOP_TIMEOUT: ${AUTOHEAL_DEFAULT_STOP_TIMEOUT:-10} + DOCKER_SOCK: /var/run/docker.sock + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - gateway-network + deploy: + resources: + limits: + memory: ${AUTOHEAL_MEMORY_LIMIT:-50m} + reservations: + memory: ${AUTOHEAL_MEMORY_RESERVATION:-20m} + +volumes: + gateway-logs: + driver: local + +networks: + gateway-network: + driver: bridge diff --git a/src/main/java/until/the/eternity/dgs/config/JwtProperties.java b/src/main/java/until/the/eternity/dgs/config/JwtProperties.java new file mode 100644 index 0000000..0816e29 --- /dev/null +++ b/src/main/java/until/the/eternity/dgs/config/JwtProperties.java @@ -0,0 +1,15 @@ +package until.the.eternity.dgs.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + private String secretKey; + private String issuer; +} diff --git a/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationFilter.java b/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..36d509f --- /dev/null +++ b/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationFilter.java @@ -0,0 +1,155 @@ +package until.the.eternity.dgs.filter; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import until.the.eternity.dgs.util.JwtTokenProvider; + +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter implements GlobalFilter, Ordered { + + private final JwtTokenProvider jwtTokenProvider; + + /** + * 인증이 필요 없는 공개 경로 목록 + */ + private static final List PUBLIC_PATHS = Arrays.asList( + "/das/api/auth/login", + "/das/api/auth/signup", + "/das/api/auth/admin/signup", + "/das/api/auth/check-email", + "/das/api/auth/check-nickname", + "/das/api/auth/signup/social", + "/das/api/auth/logout", + "/das/oauth2/", // 소셜 로그인 시작 + "/das/login/oauth2/", // 소셜 로그인 콜백 + "/actuator", + "/health" + ); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + + log.info("Processing request: {} {}, Path: '{}'", request.getMethod(), request.getURI(), path); + + // Step 1: 클라이언트가 보낸 X-Auth-* 헤더 제거 (보안상 중요!) + request = stripInternalHeaders(request); + + // Step 2: 공개 경로는 인증 불필요 + if (isPublicPath(path)) { + log.info("Public path detected, skipping authentication: {}", path); + return chain.filter(exchange.mutate().request(request).build()); + } + + log.info("Not a public path, checking authentication for: {}", path); + + // Step 3: Authorization 헤더에서 JWT 토큰 추출 + String token = extractToken(request); + if (token == null) { + log.warn("No JWT token found in Authorization header for path: {}", path); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + + // Step 4: JWT 토큰 검증 + if (!jwtTokenProvider.validateToken(token)) { + log.warn("Invalid JWT token for path: {}", path); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + + // Step 5: ACCESS 토큰만 허용 (REFRESH 토큰은 /auth/refresh에서만 사용) + String tokenType = jwtTokenProvider.getTokenType(token); + if (!"ACCESS".equals(tokenType)) { + log.warn("Token type is not ACCESS: {}", tokenType); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + + // Step 6: 사용자 정보 추출 및 ServerWebExchange에 저장 (다음 필터에서 사용) + try { + Long userId = jwtTokenProvider.getUserId(token); + String username = jwtTokenProvider.getUsername(token); + String role = jwtTokenProvider.getRole(token); + + log.debug("Authenticated user - ID: {}, Username: {}, Role: {}", userId, username, role); + + // ServerWebExchange의 attributes에 사용자 정보 저장 + exchange.getAttributes().put("userId", userId); + exchange.getAttributes().put("username", username); + exchange.getAttributes().put("role", role); + + return chain.filter(exchange.mutate().request(request).build()); + } catch (Exception e) { + log.error("Error extracting user info from token: {}", e.getMessage(), e); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + } + + /** + * Authorization 헤더에서 Bearer 토큰 추출 + */ + private String extractToken(ServerHttpRequest request) { + String authHeader = request.getHeaders().getFirst("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; + } + + /** + * 클라이언트가 보낸 X-Auth-* 헤더 제거 + * (보안상 중요: 클라이언트가 위조한 인증 헤더를 제거) + */ + private ServerHttpRequest stripInternalHeaders(ServerHttpRequest request) { + return request.mutate() + .headers(headers -> { + headers.remove("X-Auth-User-Id"); + headers.remove("X-Auth-Username"); + headers.remove("X-Auth-Roles"); + headers.remove("X-Auth-Token-Id"); + }) + .build(); + } + + /** + * 공개 경로 확인 + */ + private boolean isPublicPath(String path) { + boolean isPublic = PUBLIC_PATHS.stream().anyMatch(path::startsWith); + log.debug("Checking if path '{}' is public: {}", path, isPublic); + if (isPublic) { + String matchedPath = PUBLIC_PATHS.stream() + .filter(path::startsWith) + .findFirst() + .orElse("unknown"); + log.debug("Path '{}' matched with public path: '{}'", path, matchedPath); + } + return isPublic; + } + + /** + * 필터 우선순위 (낮을수록 먼저 실행) + * JwtAuthenticationFilter가 먼저 실행되고, 그 다음 UserContextFilter가 실행됨 + */ + @Override + public int getOrder() { + return -100; + } +} diff --git a/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java b/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java deleted file mode 100644 index aa691fc..0000000 --- a/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java +++ /dev/null @@ -1,75 +0,0 @@ -package until.the.eternity.dgs.filter; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jws; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.cloud.gateway.filter.GatewayFilter; -import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; - -import javax.crypto.SecretKey; - -@Component -public class JwtAuthenticationGatewayFilterFactory extends AbstractGatewayFilterFactory { - - private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationGatewayFilterFactory.class); - - public JwtAuthenticationGatewayFilterFactory() { - super(Config.class); - } - - @Override - public GatewayFilter apply(Config config) { - return (exchange, chain) -> { - String path = exchange.getRequest().getURI().getPath(); - logger.info("path: {}", path); - - String token = extractToken(exchange); - - if (token == null || !validateToken(token, config)) { - exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); - return exchange.getResponse().setComplete(); - } - - return chain.filter(exchange); - }; - } - - private String extractToken(ServerWebExchange exchange) { - String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - return authHeader.replace("Bearer ", ""); - } - return null; - } - - private boolean validateToken(String token, Config config) { - try { - SecretKey key = Keys.hmacShaKeyFor(config.getSecretKey().getBytes()); - Jws claimsJws = Jwts - .parserBuilder() - .requireIssuer("hyeongtaek") - .setSigningKey(key) - .build() - .parseClaimsJws(token); - - logger.info("claimsJws = {}", claimsJws.getBody().toString()); - - return true; - } catch (Exception ex) { - return false; - } - } - - public static class Config { - - public String getSecretKey() { - return "secret key 1243kljasw;ldkrfjl;asdkdfj;saldkfj "; - } - } -} diff --git a/src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java b/src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java new file mode 100644 index 0000000..c4a44b4 --- /dev/null +++ b/src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java @@ -0,0 +1,53 @@ +package until.the.eternity.dgs.filter; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * Gateway에서 검증한 사용자 정보를 내부 헤더(X-Auth-*)로 변환하여 + * 다운스트림 마이크로서비스로 전달하는 필터 + */ +@Slf4j +@Component +public class UserContextFilter implements GlobalFilter, Ordered { + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + // JwtAuthenticationFilter가 저장한 사용자 정보 읽기 + Long userId = exchange.getAttribute("userId"); + String username = exchange.getAttribute("username"); + String role = exchange.getAttribute("role"); + + // 사용자 정보가 없으면 (공개 경로 등) 그대로 통과 + if (userId == null || username == null || role == null) { + log.debug("No user context found, skipping header injection"); + return chain.filter(exchange); + } + + // 내부 헤더 추가 + ServerHttpRequest request = exchange.getRequest().mutate() + .header("X-Auth-User-Id", String.valueOf(userId)) + .header("X-Auth-Username", username) + .header("X-Auth-Roles", role) + .build(); + + log.debug("Added user context headers - User-Id: {}, Username: {}, Roles: {}", + userId, username, role); + + return chain.filter(exchange.mutate().request(request).build()); + } + + /** + * JwtAuthenticationFilter(-100) 다음에 실행되도록 우선순위 설정 + */ + @Override + public int getOrder() { + return -99; + } +} diff --git a/src/main/java/until/the/eternity/dgs/util/JwtTokenProvider.java b/src/main/java/until/the/eternity/dgs/util/JwtTokenProvider.java new file mode 100644 index 0000000..5bf10ec --- /dev/null +++ b/src/main/java/until/the/eternity/dgs/util/JwtTokenProvider.java @@ -0,0 +1,119 @@ +package until.the.eternity.dgs.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import until.the.eternity.dgs.config.JwtProperties; + +import javax.crypto.SecretKey; +import java.util.Base64; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + + /** + * Secret Key 생성 + */ + private SecretKey getSecretKey() { + byte[] keyBytes = Base64.getDecoder().decode(jwtProperties.getSecretKey()); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * JWT 토큰 검증 + * + * @param token JWT 토큰 + * @return 유효성 여부 + */ + public boolean validateToken(String token) { + try { + extractAllClaims(token); + return true; + } catch (SignatureException e) { + log.error("Invalid JWT signature: {}", e.getMessage()); + return false; + } catch (MalformedJwtException e) { + log.error("Invalid JWT token: {}", e.getMessage()); + return false; + } catch (ExpiredJwtException e) { + log.error("JWT token is expired: {}", e.getMessage()); + return false; + } catch (UnsupportedJwtException e) { + log.error("JWT token is unsupported: {}", e.getMessage()); + return false; + } catch (IllegalArgumentException e) { + log.error("JWT claims string is empty: {}", e.getMessage()); + return false; + } + } + + /** + * 토큰에서 모든 Claims 추출 + * + * @param token JWT 토큰 + * @return Claims + */ + public Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSecretKey()) + .requireIssuer(jwtProperties.getIssuer()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * 토큰에서 사용자 ID 추출 + * + * @param token JWT 토큰 + * @return 사용자 ID + */ + public Long getUserId(String token) { + Claims claims = extractAllClaims(token); + return claims.get("userId", Long.class); + } + + /** + * 토큰에서 사용자 이메일(subject) 추출 + * + * @param token JWT 토큰 + * @return 사용자 이메일 + */ + public String getUsername(String token) { + Claims claims = extractAllClaims(token); + return claims.getSubject(); + } + + /** + * 토큰에서 사용자 역할 추출 + * + * @param token JWT 토큰 + * @return 사용자 역할 + */ + public String getRole(String token) { + Claims claims = extractAllClaims(token); + return claims.get("role", String.class); + } + + /** + * 토큰 타입 확인 (ACCESS or REFRESH) + * + * @param token JWT 토큰 + * @return 토큰 타입 + */ + public String getTokenType(String token) { + Claims claims = extractAllClaims(token); + return claims.get("type", String.class); + } +} From 99e144bbad9cb42dcc4f2b6e157494cb5ac96997 Mon Sep 17 00:00:00 2001 From: "sh.lee" Date: Wed, 17 Dec 2025 21:23:17 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=9A=A9=20docker-compose=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.local | 6 +- docker-compose-local.yml | 10 +- gradlew | 0 .../dgs/filter/JwtAuthenticationFilter.java | 155 ------------------ .../dgs/filter/UserContextFilter.java | 5 +- 5 files changed, 11 insertions(+), 165 deletions(-) mode change 100644 => 100755 gradlew delete mode 100644 src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationFilter.java diff --git a/.env.local b/.env.local index 305605a..cdbb711 100644 --- a/.env.local +++ b/.env.local @@ -6,13 +6,13 @@ # === Application Configuration === SPRING_PROFILES_ACTIVE=local -SERVER_PORT=8099 +SERVER_PORT=8090 # === Downstream Service URLs === # 로컬 개발 시 호스트의 다른 서비스에 연결 (Docker 외부) AUTH_SERVER_URL=http://host.docker.internal:8091 -OPEN_API_BATCH_SERVER_URL=http://host.docker.internal:8092 -COMMUNITY_SERVER_URL=http://host.docker.internal:8093 +COMMUNITY_SERVER_URL=http://host.docker.internal:8092 +OPEN_API_BATCH_SERVER_URL=http://host.docker.internal:8093 # === Security Configuration === JWT_SECRET_KEY=e4f1a5c8d2b7e9f0a6c3d1b8e5f2c7a9d4e6f3b1a2c8d5e9f0b3a7c2d1e8f5a4 diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 8ed528d..d7018ea 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -13,7 +13,7 @@ services: image: devnogi-gateway-server:local container_name: gateway-app-local ports: - - "${SERVER_PORT:-8099}:${SERVER_PORT:-8099}" + - "${SERVER_PORT:-8090}:${SERVER_PORT:-8090}" env_file: - .env.local # 로컬 환경 변수 파일 labels: @@ -23,12 +23,12 @@ services: SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} LANG: C.UTF-8 LC_ALL: C.UTF-8 - SERVER_PORT: ${SERVER_PORT:-8099} + SERVER_PORT: ${SERVER_PORT:-8090} # === Downstream Service URLs (로컬 개발 시 호스트의 다른 서비스 연결) === AUTH_SERVER_URL: ${AUTH_SERVER_URL:-http://host.docker.internal:8091} - OPEN_API_BATCH_SERVER_URL: ${OPEN_API_BATCH_SERVER_URL:-http://host.docker.internal:8092} - COMMUNITY_SERVER_URL: ${COMMUNITY_SERVER_URL:-http://host.docker.internal:8093} + COMMUNITY_SERVER_URL: ${COMMUNITY_SERVER_URL:-http://host.docker.internal:8092} + OPEN_API_BATCH_SERVER_URL: ${OPEN_API_BATCH_SERVER_URL:-http://host.docker.internal:8093} # === Security Configuration === JWT_SECRET_KEY: ${JWT_SECRET_KEY:-e4f1a5c8d2b7e9f0a6c3d1b8e5f2c7a9d4e6f3b1a2c8d5e9f0b3a7c2d1e8f5a4} @@ -73,7 +73,7 @@ services: # Health Check healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${SERVER_PORT:-8099}/actuator/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${SERVER_PORT:-8090}/actuator/health"] interval: ${HEALTHCHECK_INTERVAL:-30s} timeout: ${HEALTHCHECK_TIMEOUT:-10s} retries: ${HEALTHCHECK_RETRIES:-3} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationFilter.java b/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationFilter.java deleted file mode 100644 index 36d509f..0000000 --- a/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationFilter.java +++ /dev/null @@ -1,155 +0,0 @@ -package until.the.eternity.dgs.filter; - -import io.jsonwebtoken.Claims; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cloud.gateway.filter.GatewayFilterChain; -import org.springframework.cloud.gateway.filter.GlobalFilter; -import org.springframework.core.Ordered; -import org.springframework.http.HttpStatus; -import org.springframework.http.server.reactive.ServerHttpRequest; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; -import until.the.eternity.dgs.util.JwtTokenProvider; - -import java.util.Arrays; -import java.util.List; - -@Slf4j -@Component -@RequiredArgsConstructor -public class JwtAuthenticationFilter implements GlobalFilter, Ordered { - - private final JwtTokenProvider jwtTokenProvider; - - /** - * 인증이 필요 없는 공개 경로 목록 - */ - private static final List PUBLIC_PATHS = Arrays.asList( - "/das/api/auth/login", - "/das/api/auth/signup", - "/das/api/auth/admin/signup", - "/das/api/auth/check-email", - "/das/api/auth/check-nickname", - "/das/api/auth/signup/social", - "/das/api/auth/logout", - "/das/oauth2/", // 소셜 로그인 시작 - "/das/login/oauth2/", // 소셜 로그인 콜백 - "/actuator", - "/health" - ); - - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - ServerHttpRequest request = exchange.getRequest(); - String path = request.getURI().getPath(); - - log.info("Processing request: {} {}, Path: '{}'", request.getMethod(), request.getURI(), path); - - // Step 1: 클라이언트가 보낸 X-Auth-* 헤더 제거 (보안상 중요!) - request = stripInternalHeaders(request); - - // Step 2: 공개 경로는 인증 불필요 - if (isPublicPath(path)) { - log.info("Public path detected, skipping authentication: {}", path); - return chain.filter(exchange.mutate().request(request).build()); - } - - log.info("Not a public path, checking authentication for: {}", path); - - // Step 3: Authorization 헤더에서 JWT 토큰 추출 - String token = extractToken(request); - if (token == null) { - log.warn("No JWT token found in Authorization header for path: {}", path); - exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); - return exchange.getResponse().setComplete(); - } - - // Step 4: JWT 토큰 검증 - if (!jwtTokenProvider.validateToken(token)) { - log.warn("Invalid JWT token for path: {}", path); - exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); - return exchange.getResponse().setComplete(); - } - - // Step 5: ACCESS 토큰만 허용 (REFRESH 토큰은 /auth/refresh에서만 사용) - String tokenType = jwtTokenProvider.getTokenType(token); - if (!"ACCESS".equals(tokenType)) { - log.warn("Token type is not ACCESS: {}", tokenType); - exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); - return exchange.getResponse().setComplete(); - } - - // Step 6: 사용자 정보 추출 및 ServerWebExchange에 저장 (다음 필터에서 사용) - try { - Long userId = jwtTokenProvider.getUserId(token); - String username = jwtTokenProvider.getUsername(token); - String role = jwtTokenProvider.getRole(token); - - log.debug("Authenticated user - ID: {}, Username: {}, Role: {}", userId, username, role); - - // ServerWebExchange의 attributes에 사용자 정보 저장 - exchange.getAttributes().put("userId", userId); - exchange.getAttributes().put("username", username); - exchange.getAttributes().put("role", role); - - return chain.filter(exchange.mutate().request(request).build()); - } catch (Exception e) { - log.error("Error extracting user info from token: {}", e.getMessage(), e); - exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); - return exchange.getResponse().setComplete(); - } - } - - /** - * Authorization 헤더에서 Bearer 토큰 추출 - */ - private String extractToken(ServerHttpRequest request) { - String authHeader = request.getHeaders().getFirst("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - return authHeader.substring(7); - } - return null; - } - - /** - * 클라이언트가 보낸 X-Auth-* 헤더 제거 - * (보안상 중요: 클라이언트가 위조한 인증 헤더를 제거) - */ - private ServerHttpRequest stripInternalHeaders(ServerHttpRequest request) { - return request.mutate() - .headers(headers -> { - headers.remove("X-Auth-User-Id"); - headers.remove("X-Auth-Username"); - headers.remove("X-Auth-Roles"); - headers.remove("X-Auth-Token-Id"); - }) - .build(); - } - - /** - * 공개 경로 확인 - */ - private boolean isPublicPath(String path) { - boolean isPublic = PUBLIC_PATHS.stream().anyMatch(path::startsWith); - log.debug("Checking if path '{}' is public: {}", path, isPublic); - if (isPublic) { - String matchedPath = PUBLIC_PATHS.stream() - .filter(path::startsWith) - .findFirst() - .orElse("unknown"); - log.debug("Path '{}' matched with public path: '{}'", path, matchedPath); - } - return isPublic; - } - - /** - * 필터 우선순위 (낮을수록 먼저 실행) - * JwtAuthenticationFilter가 먼저 실행되고, 그 다음 UserContextFilter가 실행됨 - */ - @Override - public int getOrder() { - return -100; - } -} diff --git a/src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java b/src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java index c4a44b4..d897eb2 100644 --- a/src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java +++ b/src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java @@ -19,7 +19,7 @@ public class UserContextFilter implements GlobalFilter, Ordered { @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - // JwtAuthenticationFilter가 저장한 사용자 정보 읽기 + // JwtAuthenticationGatewayFilterFactory가 저장한 사용자 정보 읽기 Long userId = exchange.getAttribute("userId"); String username = exchange.getAttribute("username"); String role = exchange.getAttribute("role"); @@ -44,7 +44,8 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { } /** - * JwtAuthenticationFilter(-100) 다음에 실행되도록 우선순위 설정 + * JWT 필터 다음에 실행되도록 우선순위 설정 + * (GlobalFilter는 GatewayFilter보다 나중에 실행됨) */ @Override public int getOrder() { From 2a74732c7df5c5687813aa194bb1e41cbaeecdda Mon Sep 17 00:00:00 2001 From: "sh.lee" Date: Wed, 17 Dec 2025 21:25:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20macOS=EC=97=90=EC=84=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20docker=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8A=B9=EC=88=98=EB=AC=B8=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...JwtAuthenticationGatewayFilterFactory.java | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java diff --git a/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java b/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java new file mode 100644 index 0000000..9f025fd --- /dev/null +++ b/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java @@ -0,0 +1,201 @@ +package until.the.eternity.dgs.filter; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import javax.crypto.SecretKey; +import java.util.Base64; + +@Slf4j +@Component +public class JwtAuthenticationGatewayFilterFactory + extends AbstractGatewayFilterFactory { + + public JwtAuthenticationGatewayFilterFactory() { + super(Config.class); + } + + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + + log.info("JWT Filter processing request: {} {}", request.getMethod(), path); + + // Step 1: 클라이언트가 보낸 X-Auth-* 헤더 제거 (보안상 중요!) + request = stripInternalHeaders(request); + + // Step 2: Authorization 헤더에서 JWT 토큰 추출 + String token = extractToken(request); + if (token == null) { + log.warn("No JWT token found in Authorization header for path: {}", path); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + + // Step 3: JWT 토큰 검증 + if (!validateToken(token, config)) { + log.warn("Invalid JWT token for path: {}", path); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + + // Step 4: ACCESS 토큰만 허용 (REFRESH 토큰은 /auth/refresh에서만 사용) + String tokenType = getTokenType(token, config); + if (!"ACCESS".equals(tokenType)) { + log.warn("Token type is not ACCESS: {}", tokenType); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + + // Step 5: 사용자 정보 추출 및 ServerWebExchange에 저장 + try { + Long userId = getUserId(token, config); + String username = getUsername(token, config); + String role = getRole(token, config); + + log.debug("Authenticated user - ID: {}, Username: {}, Role: {}", userId, username, role); + + // ServerWebExchange의 attributes에 사용자 정보 저장 + exchange.getAttributes().put("userId", userId); + exchange.getAttributes().put("username", username); + exchange.getAttributes().put("role", role); + + return chain.filter(exchange.mutate().request(request).build()); + } catch (Exception e) { + log.error("Error extracting user info from token: {}", e.getMessage(), e); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } + }; + } + + /** + * Authorization 헤더에서 Bearer 토큰 추출 + */ + private String extractToken(ServerHttpRequest request) { + String authHeader = request.getHeaders().getFirst("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; + } + + /** + * 클라이언트가 보낸 X-Auth-* 헤더 제거 + * (보안상 중요: 클라이언트가 위조한 인증 헤더를 제거) + */ + private ServerHttpRequest stripInternalHeaders(ServerHttpRequest request) { + return request.mutate() + .headers(headers -> { + headers.remove("X-Auth-User-Id"); + headers.remove("X-Auth-Username"); + headers.remove("X-Auth-Roles"); + headers.remove("X-Auth-Token-Id"); + }) + .build(); + } + + /** + * Secret Key 생성 + */ + private SecretKey getSecretKey(Config config) { + byte[] keyBytes = Base64.getDecoder().decode(config.getSecretKey()); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * JWT 토큰 검증 + */ + private boolean validateToken(String token, Config config) { + try { + extractAllClaims(token, config); + return true; + } catch (SignatureException e) { + log.error("Invalid JWT signature: {}", e.getMessage()); + return false; + } catch (MalformedJwtException e) { + log.error("Invalid JWT token: {}", e.getMessage()); + return false; + } catch (ExpiredJwtException e) { + log.error("JWT token is expired: {}", e.getMessage()); + return false; + } catch (UnsupportedJwtException e) { + log.error("JWT token is unsupported: {}", e.getMessage()); + return false; + } catch (IllegalArgumentException e) { + log.error("JWT claims string is empty: {}", e.getMessage()); + return false; + } + } + + /** + * 토큰에서 모든 Claims 추출 + */ + private Claims extractAllClaims(String token, Config config) { + return Jwts.parser() + .verifyWith(getSecretKey(config)) + .requireIssuer(config.getIssuer()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * 토큰에서 사용자 ID 추출 + */ + private Long getUserId(String token, Config config) { + Claims claims = extractAllClaims(token, config); + return claims.get("userId", Long.class); + } + + /** + * 토큰에서 사용자 이메일(subject) 추출 + */ + private String getUsername(String token, Config config) { + Claims claims = extractAllClaims(token, config); + return claims.getSubject(); + } + + /** + * 토큰에서 사용자 역할 추출 + */ + private String getRole(String token, Config config) { + Claims claims = extractAllClaims(token, config); + return claims.get("role", String.class); + } + + /** + * 토큰 타입 확인 (ACCESS or REFRESH) + */ + private String getTokenType(String token, Config config) { + Claims claims = extractAllClaims(token, config); + return claims.get("type", String.class); + } + + /** + * Configuration class for JWT authentication filter + */ + @Getter + @Setter + public static class Config { + private String secretKey; + private String issuer; + } +} From 55a88e3ee83f603949d919a09b131f23e4eacc1f Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Wed, 17 Dec 2025 22:15:21 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20push-cd-dev=EC=97=90=EC=84=9C=20dock?= =?UTF-8?q?er-compose-dev=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=B2=85?= =?UTF-8?q?=EC=82=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/push-cd-dev.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/push-cd-dev.yml b/.github/workflows/push-cd-dev.yml index 9cf5202..13303bb 100644 --- a/.github/workflows/push-cd-dev.yml +++ b/.github/workflows/push-cd-dev.yml @@ -51,6 +51,10 @@ jobs: run: | ssh -i ~/.ssh/my-key.pem ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} "mkdir -p /home/${{ secrets.SERVER_USER }}/app" + - name: Copy docker-compose file to server + run: | + scp -i ~/.ssh/my-key.pem docker-compose-dev.yaml ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:/home/${{ secrets.SERVER_USER }}/app/ + - name: Deploy and Restart Container run: | ssh -i ~/.ssh/my-key.pem ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} << 'EOF' From 29900ae12ce9a2b0ec9ff209b45f75f8a14f2497 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Thu, 18 Dec 2025 01:43:46 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20env.local=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.local | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.local b/.env.local index cdbb711..3a72fab 100644 --- a/.env.local +++ b/.env.local @@ -11,8 +11,8 @@ SERVER_PORT=8090 # === Downstream Service URLs === # 로컬 개발 시 호스트의 다른 서비스에 연결 (Docker 외부) AUTH_SERVER_URL=http://host.docker.internal:8091 -COMMUNITY_SERVER_URL=http://host.docker.internal:8092 -OPEN_API_BATCH_SERVER_URL=http://host.docker.internal:8093 +COMMUNITY_SERVER_URL=http://host.docker.internal:8093 +OPEN_API_BATCH_SERVER_URL=http://host.docker.internal:8092 # === Security Configuration === JWT_SECRET_KEY=e4f1a5c8d2b7e9f0a6c3d1b8e5f2c7a9d4e6f3b1a2c8d5e9f0b3a7c2d1e8f5a4 From 42328a6fc9c6d637fe69a9b645e6c1464aa7e6eb Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Thu, 18 Dec 2025 21:01:56 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20application.yml=EC=9D=84=20Git?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20CD=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit application.yml은 민감 정보 없이 환경 변수 플레이스홀더만 포함하고 있어 Git에 안전하게 포함 가능. CD 파이프라인에서 Docker 이미지 빌드 시 Gateway 라우팅 설정이 필요하므로 .gitignore에서 제거하고 추가함. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 5 +-- src/main/resources/application.yml | 61 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/application.yml diff --git a/.gitignore b/.gitignore index 3fbc842..e48b6be 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,4 @@ out/ /.nb-gradle/ ### VS Code ### -.vscode/ - -### Spring Boot ### -application.yml \ No newline at end of file +.vscode/ \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4a62804 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,61 @@ +server: + port: 8099 + +spring: + application: + name: gateway-server + + cloud: + gateway: + routes: + # Auth Server - 인증/인가 담당 (JWT 발급만) + - id: devnogi-auth-server + uri: ${AUTH_SERVER_URL:http://localhost:8091} + predicates: + - Path=/das/** + filters: + - StripPrefix=1 + + # Open API Batch Server - 경매 데이터 수집/분석 + - id: open-api-batch-server + uri: ${OPEN_API_BATCH_SERVER_URL:http://localhost:8092} + predicates: + - Path=/oab/** + filters: + - StripPrefix=1 + + # Community Server - 게시판/댓글/사용자 관리 + - id: devnogi-community-server + uri: ${COMMUNITY_SERVER_URL:http://localhost:8093} + predicates: + - Path=/dcs/** + filters: + - StripPrefix=1 + +management: + endpoints: + web: + exposure: + include: "*" + +# CORS 설정 +cors: + allowed-origins: "*" + allowed-methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowed-headers: "*" + allow-credentials: true + +jwt: + secret-key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} + +logging: + level: + root: INFO + org.springframework.cloud.gateway: DEBUG + reactor.netty.http.client: DEBUG