새로운 서비스에 인증/인가를 적용하려고 하는데, Istio를 통해 처리하면 애플리케이션 코드 수정 없이 인프라 레벨에서 처리할 수 있다고 해서 공부하게 된 내용입니다.
무심코 "Istio로 토큰을 검증하면 된다"고만 알고 있었는데, 실제로 어떻게 동작하는지, 어떤 리소스를 어떻게 조합해서 써야 하는지 제대로 파악이 안 되어 있어서 정리하고 공유하고자 글을 쓰게 되었습니다.
서비스에 인증/인가를 적용하려고 할 때, 보통 두 가지 방법이 있습니다.
Istio를 사용하면 사이드카 프록시(Envoy)가 요청을 가로채서 토큰 검증과 접근 제어를 수행해줍니다. 애플리케이션은 이미 검증된 요청만 받게 되므로 코드가 깔끔해지고, 인증 로직을 중앙에서 관리할 수 있다는 장점이 있습니다.
근데 Istio 문서를 보니 RequestAuthentication이랑 AuthorizationPolicy라는 두 가지 리소스가 있던데, 이게 각각 뭘 하는 건지, 왜 둘 다 필요한 건지 궁금해졌습니다.
먼저 RequestAuthentication부터 확인해보겠습니다.
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: jwt-auth
namespace: my-service
spec:
selector:
matchLabels:
app: my-api
jwtRules:
- issuer: "https://xxxx.com"
jwksUri: "https://xxxx.com/.well-known/jwks.json"
이 리소스는 JWT 토큰의 검증을 담당합니다. issuer와 jwksUri를 설정하면, Envoy 프록시가 들어오는 요청의 Authorization 헤더에서 JWT를 꺼내 서명을 검증합니다.
여기서 중요한 점이 있습니다.
마지막 JWT가 없는 경우 그냥 통과하는 게 중요했습니다. 왜 이렇게 동작하는지 잘 몰랐고, 이후에 다른 부분을 확인하면서 그 이유를 알게 되었습니다.
이유는 RequestAuthentication의 역할이 "토큰이 있다면 유효한지 확인"하는 것이기 때문입니다. JWT를 필수로 만들려면 추가 설정이 필요합니다.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: require-jwt
namespace: my-service
spec:
selector:
matchLabels:
app: my-api
action: DENY
rules:
- from:
- source:
notRequestPrincipals: ["*"]
이 리소스는 요청에 대한 접근 제어를 담당합니다. 위 설정은 notRequestPrincipals: ["*"] 조건, 즉 "JWT가 없는 요청"을 DENY하겠다는 의미입니다.
RequestAuthentication을 통과한 요청 중에서, JWT가 있으면 requestPrincipal이 설정되고 없으면 비어있습니다. 이걸 이용해서 JWT 없는 요청을 거부하는 것입니다.
AuthorizationPolicy에는 네 가지 action이 있습니다.
평가 순서가 CUSTOM → DENY → ALLOW 순이라는 게 중요합니다.
그리고 ALLOW 정책의 동작 방식이 좀 독특합니다.
즉, ALLOW 정책을 추가하는 순간 화이트리스트 방식으로 동작하기 시작합니다.
여기까지 확인해보니 왜 두 리소스를 같이 써야 하는지 이해가 됩니다.
RequestAuthentication만으로는 JWT가 없는 요청을 막을 수 없습니다. JWT 검증 자체는 "토큰이 있으면 유효한지 확인"하는 역할만 하기 때문입니다.
AuthorizationPolicy를 추가해서 notRequestPrincipals: ["*"] 조건으로 JWT가 없는 요청을 DENY해야 비로소 JWT 인증이 "필수"가 됩니다.
또한 AuthorizationPolicy에서 JWT claim 값을 조건으로 사용할 수 있습니다. 예를 들어 JWT의 issuer가 특정 값인 경우에만 허용한다거나, 특정 claim이 있는 사용자만 특정 경로에 접근하게 하는 세밀한 제어가 가능합니다.
when:
- key: request.auth.claims[iss]
values: ["https://trusted-issuer.com"]
- key: request.auth.claims[role]
values: ["admin"]
새로운 서비스에 적용한다고 가정하고 설정을 작성해보겠습니다.
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: jwt-auth
namespace: my-service
spec:
selector:
matchLabels:
app: my-api
jwtRules:
- issuer: "https://xxxx.com"
jwksUri: "https://xxxx.com/.well-known/jwks.json"
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: require-jwt
namespace: my-service
spec:
selector:
matchLabels:
app: my-api
action: DENY
rules:
- from:
- source:
notRequestPrincipals: ["*"]
to:
- operation:
notPaths: ["/health", "/ready"] # 헬스체크는 제외
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: api-access-policy
namespace: my-service
spec:
selector:
matchLabels:
app: my-api
action: ALLOW
rules:
# 헬스체크는 누구나 접근 가능
- to:
- operation:
paths: ["/health", "/ready"]
# 유효한 JWT가 있으면 API 접근 가능
- from:
- source:
requestPrincipals: ["*"]
to:
- operation:
paths: ["/api/*"]
# 특정 서비스만 내부 API 접근 가능
- from:
- source:
principals: ["cluster.local/ns/other-service/sa/backend"]
to:
- operation:
paths: ["/internal/*"]
실제 적용하기 전에 정책이 의도대로 동작하는지 확인하고 싶다면, dry-run 어노테이션을 사용할 수 있습니다.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: test-policy
namespace: my-service
annotations:
istio.io/dry-run: "true" # 실제 차단 없이 로깅만
spec:
# ... 정책 내용
이렇게 하면 정책이 매칭되는 요청을 실제로 차단하지 않고 로그만 남깁니다. 프로덕션 트래픽에 영향 없이 정책을 테스트할 수 있어서 유용합니다.
RequestAuthentication과 AuthorizationPolicy가 어떻게 동작하는지, 왜 둘을 같이 써야 하는지 확인하려고 글을 쓰면서 공부했는데, Istio의 인증/인가 구조를 좀 더 명확히 이해하게 된 것 같습니다.
RequestAuthentication은 JWT 토큰의 유효성을 검증합니다.
AuthorizationPolicy는 요청에 대한 접근 제어를 수행합니다.
notRequestPrincipals: ["*"]로 JWT 없는 요청을 거부할 수 있습니다.새로운 서비스에 적용할 때는 dry-run으로 먼저 테스트하고, 헬스체크 경로는 예외 처리하는 게 좋습니다.
아쉽게도 우리 프로젝트에서는 적용하지 못했습니다.
현재 우리 서버에서는 최소 3가지 Token 종류가 필요합니다.
여기서 1, 2는 우리가 처리할 수 있기 때문에 문제가 되지 않았습니다. 모두 JWT 토큰으로 서명하고, 우리가 제공하는 JWT 공개 서명키를 사용하면 가능했습니다.
다만, 3의 경우 서버의 맨 앞에서 Gateway같은 서비스가 있었고, Member의 AccessToken(uuid)을 가로채서 Internal Token(JWT)로 변경해서 넘기고 있었습니다.
gRPC API 호출 흐름도
Client(FE, uuid) -> Gateway(uuid => JWT Token) -> Internal Server
여기서 Internal Token은 내부에서 처리하고 있기 때문에 공개키를 제공하고 있지 않았고, 각 서버들은 인증되었다고 판단하고 처리합니다. (access token -> jwt 토큰을 변환하는 과정에서 이미 인증함)
즉, 저희는 RequestAuthentication에서 issuer 및 공개키를 등록할 수 없었고, 3)을 통해 들어오는 요청의 경우 401 Error를 반환할 수밖에 없었습니다.
기존 사내 서비스가 제공하고 있던 인증 서비스로 인해 도입은 못했지만, 많이 공부한 계기가 되었습니다.
궁금한 부분이 있다면 질문 남겨주시길 바랍니다. 긴 글 읽어주셔서 감사합니다.
재밌게 읽었습니다. 감사합니다