diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml new file mode 100644 index 0000000..95de81c --- /dev/null +++ b/.github/auto_assign.yml @@ -0,0 +1,18 @@ +addReviewers: true + +addAssignees: author + +reviewers: + - M1Nj1M + - soo96 + - jsh9057 + - gnoesnooj + - hhegi + - mimimya + +numberOfReviewers: 6 + +numberOfAssignees: 1 + +skipKeywords: + - wip diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..e7fcaca --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,18 @@ +name: Auto Assign Reviewers + +on: + pull_request: + types: [ opened, ready_for_review ] + +jobs: + assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + + steps: + - name: Auto Assign Action + uses: kentaro-m/auto-assign-action@v1.2.5 + with: + configuration-path: ".github/auto_assign.yml" diff --git a/.github/workflows/branch-name-check.yml b/.github/workflows/branch-name-check.yml new file mode 100644 index 0000000..0a3b912 --- /dev/null +++ b/.github/workflows/branch-name-check.yml @@ -0,0 +1,27 @@ +name: Branch Naming Check + +on: + push: + branches-ignore: + - main + - dev + - init + - release + +jobs: + check-branch-name: + runs-on: ubuntu-latest + + steps: + - name: Check branch name format + run: | + BRANCH_NAME=${GITHUB_REF#refs/heads/} + REGEX="^(feat|fix|refactor|docs|chore|test|bug)\/[0-9]+-.+$" + + if [[ ! $BRANCH_NAME =~ $REGEX ]]; then + echo "❌ ERROR: 브랜치 이름 형식이 틀렸습니다!" + echo "형식: 타입/이슈번호-설명 (예: feat/12-login)" + exit 1 + fi + + echo "✅ 브랜치 이름 형식이 올바릅니다: $BRANCH_NAME" diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..d806375 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,77 @@ +name: Continuous Deployment + +on: + push: + branches: + - prod + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: 1. 코드가져오기 (Checkout) + uses: actions/checkout@v4 + + - name: 2. JDK 21 설정 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: 3. .env 파일 생성 + run: | + cat << EOF > .env + ${{ secrets.PROD_ENV_FILE }} + EOF + + - name: 4. Docker Hub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: 5. Docker 이미지 빌드 및 푸시 + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + build-args: | + GPR_USER=${{ secrets.GPR_USER }} + GPR_KEY=${{ secrets.GPR_KEY }} + tags: ${{ secrets.DOCKER_USERNAME }}/gateway-service:latest + + - name: 6. SSH를 통해 배포 서버 접속 및 컨테이너 실행 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.APP_EC2_IP }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # 서버 내부의 배포 디렉토리 이동 (없으면 생성) + mkdir -p ~/app/gateway + cd ~/app/gateway + + # 서버 호스트에 .env 파일 작성 + cat << 'EOF' > .env + ${{ secrets.PROD_ENV_FILE }} + EOF + + # 최신 Docker 이미지 다운로드 + docker pull ${{ secrets.DOCKER_USERNAME }}/gateway-service:latest + + # 기존에 돌고 있는 게이트웨이 컨테이너 중지 및 삭제 + docker stop gateway || true + docker rm gateway || true + + # 불필요한 구버전 이미지 정리 + docker image prune -f + + # 새 컨테이너 실행 (--env-file 옵션으로 뭉텅이 .env 주입) + docker run -d \ + --name gateway \ + -p 8080:8080 \ + --env-file .env \ + --restart always \ + ${{ secrets.DOCKER_USERNAME }}/gateway-service:latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b228e81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: build + +on: + pull_request: + branches: + - dev + - main + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + env: + JWT_SECRET: ${{secrets.JWT_SECRET}} + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for Gradle + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew clean build --stacktrace diff --git a/.gitignore b/.gitignore index c2065bc..b012df5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.env +src/main/resources/.env + HELP.md .gradle build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93c220a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /workspace +COPY . . + +# [나중에 공통모듈 쓸 때 주석 해제] +# ARG GPR_USER +# ARG GPR_KEY +# ENV GPR_USER=${GPR_USER} +# ENV GPR_KEY=${GPR_KEY} + +RUN chmod +x gradlew + +# 현재는 순수하게 빌드만 진행 (공통모듈 미사용 버전) +RUN ./gradlew bootJar --no-daemon + +# [나중에 공통모듈 쓸 때 주석 해제할 라인] +# RUN ./gradlew bootJar --no-daemon -Pgpr.user="${GPR_USER}" -Pgpr.key="${GPR_KEY}" + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /workspace/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/build.gradle b/build.gradle index ef5233e..b97a608 100644 --- a/build.gradle +++ b/build.gradle @@ -1,24 +1,25 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.13' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.13' + id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.pagley' +group = 'com.pagely' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } repositories { - mavenCentral() + mavenCentral() } ext { - set('springCloudVersion', "2025.0.2") + set('springCloudVersion', "2025.0.2") + jjwtVersion = '0.12.6' } dependencies { @@ -34,22 +35,26 @@ dependencies { implementation 'io.micrometer:micrometer-tracing-bridge-brave' implementation 'io.zipkin.reporter2:zipkin-reporter-brave' // Lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'io.projectreactor:reactor-test' - testCompileOnly 'org.projectlombok:lombok' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testAnnotationProcessor 'org.projectlombok:lombok' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + // jjwt - 직접 버전 명시 + implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}" + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' + testCompileOnly 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testAnnotationProcessor 'org.projectlombok:lombok' } dependencyManagement { - imports { - mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" - } + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/settings.gradle b/settings.gradle index a239fa0..a9f5265 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'gateway' +rootProject.name = 'gatewayserver' diff --git a/src/main/java/com/pagely/gateway/GatewayApplication.java b/src/main/java/com/pagely/gateway/GatewayApplication.java new file mode 100644 index 0000000..6139aca --- /dev/null +++ b/src/main/java/com/pagely/gateway/GatewayApplication.java @@ -0,0 +1,16 @@ +package com.pagely.gateway; + +import com.pagely.gateway.infrastructure.security.JwtProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(JwtProperties.class) +public class GatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } + +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/BusinessException.java b/src/main/java/com/pagely/gateway/domain/exception/BusinessException.java new file mode 100644 index 0000000..a35fb9f --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/BusinessException.java @@ -0,0 +1,18 @@ +package com.pagely.gateway.domain.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, String detailMessage) { + super(detailMessage); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/CommonErrorCode.java b/src/main/java/com/pagely/gateway/domain/exception/CommonErrorCode.java new file mode 100644 index 0000000..cc29ba5 --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/CommonErrorCode.java @@ -0,0 +1,36 @@ +package com.pagely.gateway.domain.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 공통 에러 코드. + */ +@Getter +public enum CommonErrorCode implements ErrorCode { + + // 4xx + INVALID_INPUT("입력값이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), + RESOURCE_NOT_FOUND("요청한 리소스를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + UNAUTHORIZED("인증이 필요합니다.", HttpStatus.UNAUTHORIZED), + FORBIDDEN("접근 권한이 없습니다.", HttpStatus.FORBIDDEN), + CONFLICT("요청이 현재 상태와 충돌합니다.", HttpStatus.CONFLICT), + UNSUPPORTED_MEDIA_TYPE("지원하지 않는 미디어 타입입니다.", HttpStatus.UNSUPPORTED_MEDIA_TYPE), + TOO_MANY_REQUESTS("요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.", HttpStatus.TOO_MANY_REQUESTS), + + // 5xx + INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + SERVICE_UNAVAILABLE("서비스를 일시적으로 사용할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE), + EVENT_PUBLISH_FAILURE("이벤트 발행에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + + + private final String code; + private final String message; + private final HttpStatus httpStatus; + + CommonErrorCode(String message, HttpStatus httpStatus) { + this.code = this.name(); + this.message = message; + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/ErrorCode.java b/src/main/java/com/pagely/gateway/domain/exception/ErrorCode.java new file mode 100644 index 0000000..47532c2 --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/ErrorCode.java @@ -0,0 +1,11 @@ +package com.pagely.gateway.domain.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + String getCode(); + + String getMessage(); + + HttpStatus getHttpStatus(); +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/ErrorResponse.java b/src/main/java/com/pagely/gateway/domain/exception/ErrorResponse.java new file mode 100644 index 0000000..7ab1235 --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/ErrorResponse.java @@ -0,0 +1,42 @@ +package com.pagely.gateway.domain.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 에러 상세 정보 DTO. + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponse { + + private String code; + private String message; + private List fieldErrors; + + public static ErrorResponse of(String code, String message) { + return new ErrorResponse(code, message, null); + } + + public static ErrorResponse of(String code, String message, List fieldErrors) { + return new ErrorResponse(code, message, fieldErrors); + } + + /** + * Bean Validation 필드 오류 정보. + */ + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor(staticName = "of") + public static class FieldError { + private String field; + private Object rejectedValue; + private String reason; + } +} diff --git a/src/main/java/com/pagely/gateway/domain/exception/GatewayErrorCode.java b/src/main/java/com/pagely/gateway/domain/exception/GatewayErrorCode.java new file mode 100644 index 0000000..4cad298 --- /dev/null +++ b/src/main/java/com/pagely/gateway/domain/exception/GatewayErrorCode.java @@ -0,0 +1,20 @@ +package com.pagely.gateway.domain.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum GatewayErrorCode implements ErrorCode { + INVALID_TOKEN_FORMAT("Authorization 헤더는 'Bearer {token}' 형식이어야 합니다.", HttpStatus.UNAUTHORIZED), + EMPTY_TOKEN("토큰이 비어있습니다.", HttpStatus.UNAUTHORIZED), + INVALID_TOKEN("토큰이 유효하지 않거나 만료되었습니다.", HttpStatus.UNAUTHORIZED); + private final String code; + private final String message; + private final HttpStatus httpStatus; + + GatewayErrorCode(String message, HttpStatus httpStatus) { + this.code = this.name(); + this.message = message; + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/config/ErrorConfig.java b/src/main/java/com/pagely/gateway/infrastructure/config/ErrorConfig.java new file mode 100644 index 0000000..43bcca0 --- /dev/null +++ b/src/main/java/com/pagely/gateway/infrastructure/config/ErrorConfig.java @@ -0,0 +1,20 @@ +package com.pagely.gateway.infrastructure.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pagely.gateway.infrastructure.exception.GatewayExceptionHandler; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; + +@Configuration +public class ErrorConfig { + + @Bean + @Primary // 같은 타입의 빈이 여러 개일 때 이 녀석을 1순위로! + @Order(-2) // 기본 핸들러(-1)보다 높은 순위 + public ErrorWebExceptionHandler gatewayExceptionHandler(ObjectMapper objectMapper) { + return new GatewayExceptionHandler(objectMapper); + } +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java b/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java new file mode 100644 index 0000000..8e10a01 --- /dev/null +++ b/src/main/java/com/pagely/gateway/infrastructure/exception/GatewayExceptionHandler.java @@ -0,0 +1,70 @@ +package com.pagely.gateway.infrastructure.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pagely.gateway.domain.exception.BusinessException; +import com.pagely.gateway.domain.exception.CommonErrorCode; +import com.pagely.gateway.domain.exception.ErrorCode; +import com.pagely.gateway.domain.exception.ErrorResponse; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * Gateway는 WebFlux 기반의 정적인 라이브러리 환경이기 때문에, 일반적인 MVC의 @RestControllerAdvice를 사용할 수 없습니다. + * + */ +@Slf4j +@RequiredArgsConstructor +public class GatewayExceptionHandler implements ErrorWebExceptionHandler { + + private final ObjectMapper objectMapper; + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + ServerHttpResponse response = exchange.getResponse(); + + if (response.isCommitted()) { + return Mono.error(ex); + } + + // 1. 에러 코드 추출 + ErrorCode errorCode = determineErrorCode(ex); + + // 2. 응답 설정 + response.setStatusCode(errorCode.getHttpStatus()); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + // 3. 공통 에러 응답 바디 + Map apiResponse = Map.of( + "success", false, + "error", ErrorResponse.of( + errorCode.getCode(), + ex instanceof BusinessException ? ex.getMessage() : errorCode.getMessage() + ) + ); + + return response.writeWith(Mono.fromSupplier(() -> { + try { + byte[] bytes = objectMapper.writeValueAsBytes(apiResponse); + return response.bufferFactory().wrap(bytes); + } catch (Exception e) { + log.error("Error writing response", e); + return response.bufferFactory() + .wrap("{\"success\":false,\"error\":{\"code\":\"INTERNAL_SERVER_ERROR\"}}".getBytes()); + } + })); + } + + private ErrorCode determineErrorCode(Throwable ex) { + if (ex instanceof BusinessException) { + return ((BusinessException) ex).getErrorCode(); + } + // Gateway 자체에서 발생하는 예외들에 대한 매핑 + return CommonErrorCode.INTERNAL_SERVER_ERROR; + } +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java b/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java new file mode 100644 index 0000000..d88e80f --- /dev/null +++ b/src/main/java/com/pagely/gateway/infrastructure/security/AuthenticationFilter.java @@ -0,0 +1,122 @@ +package com.pagely.gateway.infrastructure.security; + +import com.pagely.gateway.domain.exception.BusinessException; +import com.pagely.gateway.domain.exception.CommonErrorCode; +import com.pagely.gateway.domain.exception.GatewayErrorCode; +import io.jsonwebtoken.JwtException; +import java.util.List; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * JWT 인증 필터 (Reactive GlobalFilter). + * + *

처리 흐름

+ *
    + *
  1. 요청 경로가 Public Path 매칭 → 통과
  2. + *
  3. Authorization 헤더 추출 (없으면 401)
  4. + *
  5. "Bearer " prefix 검증 (없으면 401)
  6. + *
  7. JWT 서명/만료 검증 (실패 시 401)
  8. + *
  9. 클레임 추출하여 X-User-Id, X-User-Role 헤더 주입
  10. + *
  11. 다운스트림 서비스로 전달
  12. + *
+ * + *

Public Path 매칭은 Ant 패턴 (예: /api/v1/auth/**) 지원.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthenticationFilter implements GlobalFilter, Ordered { + + private static final String BEARER_PREFIX = "Bearer "; + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_USER_ROLE = "X-User-Role"; + + private final JwtTokenProvider jwtTokenProvider; + private final JwtProperties jwtProperties; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + HttpMethod method = request.getMethod(); + + // [1] Public Path 매칭 / 회원가입 경로 (POST /users/) → 인증 우회 + if (isPublicPath(path) || + "/api/v1/users".equals(path) && HttpMethod.POST.equals(method)) { + return chain.filter(exchange); + } + + // [2] Authorization 헤더 추출 + String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (authHeader == null || authHeader.isBlank()) { + return Mono.error(new BusinessException(CommonErrorCode.UNAUTHORIZED)); + } + + // [3] Bearer prefix 검증 + if (!authHeader.startsWith(BEARER_PREFIX)) { + return Mono.error(new BusinessException(GatewayErrorCode.INVALID_TOKEN_FORMAT)); + } + + String token = authHeader.substring(BEARER_PREFIX.length()).trim(); + if (token.isEmpty()) { + return Mono.error(new BusinessException(GatewayErrorCode.EMPTY_TOKEN)); + } + + // [4] JWT 검증 + if (!jwtTokenProvider.validateToken(token)) { + return Mono.error(new BusinessException(GatewayErrorCode.INVALID_TOKEN)); + } + + // [5] 클레임 추출 + JwtClaims claims; + try { + claims = jwtTokenProvider.parseClaims(token); + } catch (JwtException | IllegalStateException e) { + log.warn("JWT 파싱 실패 (검증은 통과): {}", e.getMessage()); + return Mono.error(new BusinessException(GatewayErrorCode.INVALID_TOKEN)); + } + + // [6] 헤더 주입 후 다음 필터로 + ServerHttpRequest mutatedRequest = request.mutate() + .header(HEADER_USER_ID, claims.userId().toString()) + .header(HEADER_USER_ROLE, claims.role()) + .build(); + +/* log.debug("인증 성공: userId={}, role={}, path={}", + claims.userId(), claims.role(), path);*/ + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + + /** + * 요청 경로가 Public Path 패턴 중 하나에 매칭되는지. + */ + private boolean isPublicPath(String path) { + List publicPaths = jwtProperties.publicPaths(); + if (publicPaths == null || publicPaths.isEmpty()) { + return false; + } + return publicPaths.stream() + .anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + /** + * 다른 GlobalFilter 보다 일찍 실행. + */ + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 100; + } +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/security/JwtClaims.java b/src/main/java/com/pagely/gateway/infrastructure/security/JwtClaims.java new file mode 100644 index 0000000..c7e2269 --- /dev/null +++ b/src/main/java/com/pagely/gateway/infrastructure/security/JwtClaims.java @@ -0,0 +1,14 @@ +package com.pagely.gateway.infrastructure.security; + +import java.util.UUID; + +/** + * JWT 토큰에서 추출한 클레임 정보. + * + *

표준 클레임 sub는 유저 UUID, 커스텀 클레임 role은 권한 문자열.

+ */ +public record JwtClaims( + UUID userId, + String role +) { +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/security/JwtProperties.java b/src/main/java/com/pagely/gateway/infrastructure/security/JwtProperties.java new file mode 100644 index 0000000..613999e --- /dev/null +++ b/src/main/java/com/pagely/gateway/infrastructure/security/JwtProperties.java @@ -0,0 +1,19 @@ +package com.pagely.gateway.infrastructure.security; + +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * JWT 관련 설정 매핑. + * + *

application.yml 의 jwt 섹션을 자동 바인딩.

+ * + * @param secret JWT 검증용 비밀키 (HS256, 32바이트 이상) + * @param publicPaths JWT 검증을 우회할 경로 패턴 목록 + */ +@ConfigurationProperties(prefix = "jwt") // GatewayApplication - @EnableConfigurationProperties 추가 +public record JwtProperties( + String secret, + List publicPaths +) { +} diff --git a/src/main/java/com/pagely/gateway/infrastructure/security/JwtTokenProvider.java b/src/main/java/com/pagely/gateway/infrastructure/security/JwtTokenProvider.java new file mode 100644 index 0000000..ce7ded3 --- /dev/null +++ b/src/main/java/com/pagely/gateway/infrastructure/security/JwtTokenProvider.java @@ -0,0 +1,103 @@ +package com.pagely.gateway.infrastructure.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * JWT 토큰 검증 및 클레임 파싱. + * + *

Gateway는 토큰의 검증만 책임. 토큰 생성은 User Service의 로그인 API가 담당. + * 양쪽 서비스가 동일한 jwt.secret 환경변수를 공유해야 한다.

+ * + *

HMAC HS256 사용

+ *
    + *
  • 대칭키 방식 (생성/검증 동일 키)
  • + *
  • 에 따라 키는 256비트(32바이트) 이상
  • + *
  • 환경변수 JWT_SECRET 으로 주입 (운영)
  • + *
+ */ +@Slf4j +@Component +public class JwtTokenProvider { + + private static final String CLAIM_ROLE = "role"; + + private final SecretKey secretKey; + + public JwtTokenProvider(JwtProperties jwtProperties) { + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8)); + } + + /** + * 토큰 유효성 검증 (서명, 만료, 형식). + * + * @return true = 정상 토큰, false = 검증 실패 + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return true; + } catch (ExpiredJwtException e) { + log.debug("JWT 만료: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.debug("지원하지 않는 JWT 형식: {}", e.getMessage()); + } catch (MalformedJwtException e) { + log.debug("잘못된 JWT 형식: {}", e.getMessage()); + } catch (SignatureException e) { + log.debug("JWT 서명 검증 실패: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + log.debug("JWT 클레임이 비어있음: {}", e.getMessage()); + } catch (JwtException e) { + log.debug("JWT 검증 실패: {}", e.getMessage()); + } + return false; + } + + /** + * 토큰에서 클레임 추출. + * + *

호출 전 {@link #validateToken(String)}으로 검증 필수. + * 검증 실패한 토큰을 파싱하면 예외 발생.

+ * + * @return JwtClaims (userId UUID, role String) + * @throws JwtException 파싱 실패 시 + * @throws IllegalStateException sub 클레임이 유효한 UUID 아닐 때 + */ + public JwtClaims parseClaims(String token) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + UUID userId = parseUserId(claims.getSubject()); + String role = claims.get(CLAIM_ROLE, String.class); + + return new JwtClaims(userId, role); + } + + private UUID parseUserId(String subject) { + if (subject == null || subject.isBlank()) { + throw new IllegalStateException("JWT sub 클레임이 비어있습니다."); + } + try { + return UUID.fromString(subject); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("JWT sub 클레임이 유효한 UUID가 아닙니다: " + subject, e); + } + } +} diff --git a/src/main/java/com/pagley/gateway/GatewayApplication.java b/src/main/java/com/pagley/gateway/GatewayApplication.java deleted file mode 100644 index 9365833..0000000 --- a/src/main/java/com/pagley/gateway/GatewayApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.pagley.gateway; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class GatewayApplication { - - public static void main(String[] args) { - SpringApplication.run(GatewayApplication.class, args); - } - -} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..bc5d641 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,93 @@ +server: + port: 8080 + +spring: + application: + name: gateway + config: + import: + - optional:configserver:${CONFIG_SERVER_URL:http://pagely-config:8888} + - optional:classpath:.env[.properties] + profiles: + active: prod + + cloud: + gateway: + server: + webflux: + routes: + # Auth Service (19091) + - id: auth-service + uri: lb://authservice + predicates: + - Path=/api/v1/auth/** + + # AI Service (19021) + - id: ai-service + uri: lb://aiservice + predicates: + - Path=/api/v1/ai/** + + # User Service (19001) + - id: user-service + uri: lb://userservice + predicates: + - Path=/api/v1/users/** + + # Market Service (19031) + - id: market-service + order: 2 + uri: lb://marketservice + predicates: + - Path=/api/v1/sale-posts/**, /api/v1/orders/**, /api/v1/sellers/**, /api/v1/buyers/** + + # Payment Service (19041 결제 + Webhook + 구매자 결제내역) + - id: payment-service + order: 1 # 숫자가 낮을수록 우선순위가 높음 (buyers) + uri: lb://paymentservice + predicates: + - Path=/api/v1/payments/**, /api/v1/payment/webhook, /api/v1/buyers/me/payments + + # Book Service (19051) + - id: book-service + uri: lb://bookservice + predicates: + - Path=/api/v1/books/** + + # Report Service (19061) + - id: report-service + uri: lb://reportservice + predicates: + - Path=/api/v1/reports/** + + # Meeting Service (19011) + - id: meeting-service + uri: lb://meetingservice + predicates: + - Path=/api/v1/meetings/** + + # Map Service (19071) : MVP 이후 수정 + - id: map-service + uri: lb://mapservice + predicates: + - Path=/api/v1/map/** + + +eureka: + instance: + prefer-ip-address: true + hostname: ${HOSTNAME:localhost} + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: ${EUREKA_SERVER_URL:http://pagely-eureka:8000/eureka/} + + +jwt: + secret: ${JWT_SECRET} + # 생성 명령: openssl rand -base64 64 + public-paths: + - /api/v1/auth/** + - /actuator/** + - /api/v1/payment/webhook diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d0a679f..90fa333 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,98 @@ +server: + port: 8080 + spring: application: - name: gatewayserver + name: gateway + config: + import: + - optional:configserver:http://localhost:8888 + - optional:classpath:.env[.properties] + profiles: + active: dev + + cloud: + gateway: + server: + webflux: + routes: + # Auth Service (19091) + - id: auth-service + uri: lb://authservice + predicates: + - Path=/api/v1/auth/** + + # AI Service (19021) + - id: ai-service + uri: lb://aiservice + predicates: + - Path=/api/v1/ai/** + + # User Service (19001) + - id: user-service + uri: lb://userservice + predicates: + - Path=/api/v1/users/** + + # Market Service (19031) + - id: market-service + order: 2 + uri: lb://marketservice + predicates: + - Path=/api/v1/sale-posts/**, /api/v1/orders/**, /api/v1/sellers/**, /api/v1/buyers/** + + # Payment Service (19041 결제 + Webhook + 구매자 결제내역) + - id: payment-service + order: 1 # 숫자가 낮을수록 우선순위가 높음 (buyers) + uri: lb://paymentservice + predicates: + - Path=/api/v1/payments/**, /api/v1/payment/webhook, /api/v1/buyers/me/payments + + # Book Service (19051) + - id: book-service + uri: lb://bookservice + predicates: + - Path=/api/v1/books/** + + # Report Service (19061) + - id: report-service + uri: lb://reportservice + predicates: + - Path=/api/v1/reports/** + + # Meeting Service (19011) + - id: meeting-service + uri: lb://meetingservice + predicates: + - Path=/api/v1/meetings/** + + # Map Service (19071) : MVP 이후 수정 + - id: map-service + uri: lb://mapservice + predicates: + - Path=/api/v1/map/** + + # Chat Service (모임 채팅 / 거래 채팅) : MVP 이후 수정 + - id: chat-service + uri: http://localhost:19081 + predicates: + - Path=/api/v1/chat/** + +eureka: + instance: + prefer-ip-address: true + hostname: ${HOSTNAME:localhost} + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: ${EUREKA_SERVER_URL:http://localhost:8000/eureka/} + + +jwt: + secret: ${JWT_SECRET} + # 생성 명령: openssl rand -base64 64 + public-paths: + - /api/v1/auth/** + - /actuator/** + - /api/v1/payment/webhook diff --git a/src/test/java/com/pagley/gateway/GatewayApplicationTests.java b/src/test/java/com/pagely/gateway/GatewayApplicationTests.java similarity index 68% rename from src/test/java/com/pagley/gateway/GatewayApplicationTests.java rename to src/test/java/com/pagely/gateway/GatewayApplicationTests.java index b3c9635..b82b348 100644 --- a/src/test/java/com/pagley/gateway/GatewayApplicationTests.java +++ b/src/test/java/com/pagely/gateway/GatewayApplicationTests.java @@ -1,4 +1,4 @@ -package com.pagley.gateway; +package com.pagely.gateway; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -6,8 +6,8 @@ @SpringBootTest class GatewayApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/pagely/gateway/JwtTestTokenGenerator.java b/src/test/java/com/pagely/gateway/JwtTestTokenGenerator.java new file mode 100644 index 0000000..c10937a --- /dev/null +++ b/src/test/java/com/pagely/gateway/JwtTestTokenGenerator.java @@ -0,0 +1,70 @@ +package com.pagely.gateway; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Properties; +import javax.crypto.SecretKey; + +/** + * 개발용 JWT 토큰 생성 도구. main 메서드 실행하여 콘솔에 토큰 출력 → 테스트에 사용. + * + *

실제 로그인 API 구현 전까지 임시 사용. User Service 로그인 이슈 + * 완료되면 이 클래스 제거.

+ */ +public class JwtTestTokenGenerator { + + public static void main(String[] args) { + // application.yml의 기본 secret과 동일해야 함 + String secret = null; + + try { + // 1. .env 파일 경로 지정 (프로젝트 루트 기준) + // 만약 src/main/resources 아래에 있다면 아래 경로를 사용하세요. + String envPath = "src/main/resources/.env"; + + Properties props = new Properties(); + props.load(new FileInputStream(envPath)); + + // 2. .env 파일에서 JWT_SECRET 키로 값을 가져옴 + secret = props.getProperty("JWT_SECRET"); + + } catch (IOException e) { + System.err.println(".env 파일을 찾을 수 없습니다: " + e.getMessage()); + } + if (secret == null || secret.isBlank()) { + throw new IllegalStateException( + "JWT_SECRET 환경변수가 설정되지 않았습니다. " + + "예: export JWT_SECRET=$(openssl rand -base64 64)" + ); + } + SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + + // 정상 토큰 (1시간 유효) + String validToken = Jwts.builder() + .subject("550e8400-e29b-41d4-a716-446655440000") + .claim("role", "USER") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 3600_000)) + .signWith(key) + .compact(); + + // 만료 토큰 + String expiredToken = Jwts.builder() + .subject("550e8400-e29b-41d4-a716-446655440000") + .claim("role", "USER") + .issuedAt(new Date(System.currentTimeMillis() - 7200_000)) + .expiration(new Date(System.currentTimeMillis() - 3600_000)) // 1시간 전 만료 + .signWith(key) + .compact(); + + System.out.println("=== 정상 토큰 ==="); + System.out.println(validToken); + System.out.println(); + System.out.println("=== 만료 토큰 ==="); + System.out.println(expiredToken); + } +}