diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..3a72fab --- /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=8090 + +# === Downstream Service URLs === +# 로컬 개발 시 호스트의 다른 서비스에 연결 (Docker 외부) +AUTH_SERVER_URL=http://host.docker.internal:8091 +COMMUNITY_SERVER_URL=http://host.docker.internal:8093 +OPEN_API_BATCH_SERVER_URL=http://host.docker.internal:8092 + +# === 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/.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' 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/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..d7018ea --- /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:-8090}:${SERVER_PORT:-8090}" + 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:-8090} + + # === Downstream Service URLs (로컬 개발 시 호스트의 다른 서비스 연결) === + AUTH_SERVER_URL: ${AUTH_SERVER_URL:-http://host.docker.internal:8091} + 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} + 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:-8090}/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/gradlew b/gradlew old mode 100644 new mode 100755 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/JwtAuthenticationGatewayFilterFactory.java b/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java index aa691fc..9f025fd 100644 --- a/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java +++ b/src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java @@ -1,23 +1,30 @@ package until.the.eternity.dgs.filter; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jws; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.Keys; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +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 { - - private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationGatewayFilterFactory.class); +public class JwtAuthenticationGatewayFilterFactory + extends AbstractGatewayFilterFactory { public JwtAuthenticationGatewayFilterFactory() { super(Config.class); @@ -26,50 +33,169 @@ public JwtAuthenticationGatewayFilterFactory() { @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { - String path = exchange.getRequest().getURI().getPath(); - logger.info("path: {}", path); + 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(); + } - String token = extractToken(exchange); + // Step 3: JWT 토큰 검증 + if (!validateToken(token, config)) { + log.warn("Invalid JWT token for path: {}", path); + exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); + return exchange.getResponse().setComplete(); + } - if (token == null || !validateToken(token, config)) { + // 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(); } - return chain.filter(exchange); + // 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(); + } }; } - private String extractToken(ServerWebExchange exchange) { - String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); + /** + * Authorization 헤더에서 Bearer 토큰 추출 + */ + private String extractToken(ServerHttpRequest request) { + String authHeader = request.getHeaders().getFirst("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { - return authHeader.replace("Bearer ", ""); + return authHeader.substring(7); } 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); + /** + * 클라이언트가 보낸 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(); + } - logger.info("claimsJws = {}", claimsJws.getBody().toString()); + /** + * 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 (Exception ex) { + } 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; } } - public static class Config { + /** + * 토큰에서 모든 Claims 추출 + */ + private Claims extractAllClaims(String token, Config config) { + return Jwts.parser() + .verifyWith(getSecretKey(config)) + .requireIssuer(config.getIssuer()) + .build() + .parseSignedClaims(token) + .getPayload(); + } - public String getSecretKey() { - return "secret key 1243kljasw;ldkrfjl;asdkdfj;saldkfj "; - } + /** + * 토큰에서 사용자 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; } } 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..d897eb2 --- /dev/null +++ b/src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java @@ -0,0 +1,54 @@ +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) { + // JwtAuthenticationGatewayFilterFactory가 저장한 사용자 정보 읽기 + 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()); + } + + /** + * JWT 필터 다음에 실행되도록 우선순위 설정 + * (GlobalFilter는 GatewayFilter보다 나중에 실행됨) + */ + @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); + } +} 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