본문 바로가기

Java/기술회고

내부 서비스 인증을 X-Service-Token에서 OAuth 2.0으로 바꾼 이유

서비스를 분리하면서 내부 인증은 한동안 X-Service-Token 방식으로 두고 있었다.
Feign 요청마다 헤더 하나 붙이면 됐고, 단순했다.

그런데 auction-service → product-service, auction-service → user-service, chat-service → auction-service  
같은 호출이 늘어나면서 이 방식을 계속 가져가는 게 맞나 싶었다.

 

전체 코드는 여기서 볼 수 있다

 

GitHub - mangtaeeee/bowchat-auction

Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.

github.com

기존 구조

외부 사용자 요청은 비교적 명확했다.

user-service가 JWT 발급
각 서비스는 JWT 클레임(userId, email, role)만 파싱
user-service DB 재조회 없음

문제는 내부 호출이었다.

auction-service -> product-service /internal/products/{id}/seller
auction-service -> user-service    /internal/users/{id}
chat-service    -> auction-service /internal/auctions/{id}

내부 API는 외부 사용자 요청과 성격이 다르다.
호출 주체가 "사람"이 아니라 "서비스"다.
로그인한 사용자 권한이 아니라 서비스 간 신뢰를 확인해야 한다.

처음엔 이렇게 막았다.

 

bowchat-auction/auction-service/src/main/java/com/example/auctionservice/auth/config/FeignConfig.java at main · mangtaeeee/bowc

Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.

github.com

 

// Feign 쪽
requestTemplate.header("X-Service-Token", internalSecret);

// 받는 쪽
if (!internalSecret.equals(serviceToken)) {
    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
    return false;
}

Docker Network로 /internal/** 를 외부에서 직접 때리기 어렵게 막고,
공용 시크릿 하나로 서비스 간 신뢰를 확인하는 구조였다.
초기에는 충분히 실용적이었다.

한계가 생긴 지점

호출 주체를 구분할 수 없다.

X-Service-Token 은 결국 "이 비밀값을 알고 있는가"만 확인한다.
auction-service 가 호출했는지 chat-service 가 호출했는지 구분이 안 된다.
둘 다 같은 비밀값을 알고 있으면 통과다.

권한을 나눌 수 없다.

예를 들어 auction-serviceproduct-service 의 판매자 조회 API를 호출할 수 있고,
chat-service 는 불가라는 정책을 만들고 싶어도 구현 방법이 없다.
"있으면 통과, 없으면 차단" 두 가지밖에 없다.

유출 범위가 너무 크다.

공용 시크릿 하나가 새면 내부 API 전체가 위험해진다.
서비스별로 credential이 나뉘어 있으면 영향 범위를 분리할 수 있다.

방향 전환

결국 이렇게 나눠야 했다.

외부 사용자 요청 → user-service JWT
내부 서비스 요청 → Keycloak OAuth 2.0 client credentials

사용자 인증과 내부 서비스 인증을 섞지 않는 게 목표였다.

Keycloak을 auth server로 두고, 각 서비스를 클라이언트로 등록했다.
auction-service, product-service, user-service, chat-service 각각이 자기 client_id, client_secret 으로 액세스 토큰을 발급받는다.

이러면 "누가 호출했는지"를 토큰 기준으로 구분할 수 있고,
scope로 권한도 나눌 수 있다.

지금은 auction.internal.read  scope 하나로 맞췄지만
나중에 product.read, user.read 처럼 쪼개는 것도 가능한 구조다.

적용

Keycloak 자동 초기화

로컬에서 매번 realm을 수동으로 만드는 건 비현실적이라
realm import 파일을 같이 두고 docker compose up  시 자동 import 되게 했다.

 

bowchat-auction/infra at main · mangtaeeee/bowchat-auction

Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.

github.com

 

infra/
  docker-compose-local.yml
  keycloak/import/bowchat-realm.json

이 안에 realm, client 등록, scope 설정이 다 들어있어서
올리면 바로 쓸 수 있는 상태가 된다.

보안 체인 분리

각 서비스 보안 설정을 일반 API용과 /internal/**  전용으로 나눴다.

 

bowchat-auction/auction-service/src/main/java/com/example/auctionservice/auth/config/SecurityConfig.java at main · mangtaeeee/b

Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.

github.com

 

@Bean
@Order(1)
public SecurityFilterChain internalFilterChain(...) {
    http
        .securityMatcher("/internal/**")
        .authorizeHttpRequests(auth -> auth
            .anyRequest().hasAnyAuthority(
                "ROLE_INTERNAL_SERVICE",
                "SCOPE_auction.internal.read"
            )
        );
}

Feign에 Bearer 토큰 추가

호출하는 서비스는 Keycloak에서 토큰을 발급받아 요청에 붙인다.

 

bowchat-auction/auction-service/src/main/java/com/example/auctionservice/auth/config/OAuth2ClientConfig.java at main · mangtaee

Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.

github.com

 

authorizedClientManager
    .map(manager -> OAuth2ClientConfig.resolveAccessToken(manager, clientRegistrationId))
    .ifPresent(token -> requestTemplate.header(HttpHeaders.AUTHORIZATION, "Bearer " + token));

흐름은 이렇다.

auction-service
 → Keycloak에 토큰 요청
 → access token 발급
 → product-service /internal/** 호출
 → product-service가 issuer/scope 검증

기존 X-Service-Token은 바로 끊지 않았다

한 번에 전부 교체하면 디버깅이 어렵고 연동이 깨질 수 있다.
지금은 OAuth 2.0 Bearer가 정식 경로고, 기존 토큰은 안전한 전환용으로 남겨 뒀다.

 

정리

X-Service-Token 은 빠르고 단순해서 초기에 유용했다.
하지만 서비스 간 호출이 늘어나니 "누가 호출했는지, 어떤 권한으로 호출했는지"를 구분할 수 없다는 게 실질적인 문제가 됐다.

결국 이번 작업도 "보안을 더 세게 한다"보다
"인증 책임을 어디에 둘지, 어떤 경로를 표준으로 삼을지 정리하는 작업"에 더 가까웠다.