Home MSA 생태계에서 우아하게 유저 인증/인가 정보를 다루기 (Passport)
Post
Cancel

MSA 생태계에서 우아하게 유저 인증/인가 정보를 다루기 (Passport)

Passport가 필요한 시점

Gateway, ServiceDiscovery를 결합하여 MSA환경 생태계를 구축했다.

클라이언트는 로그인 후 발급받은 JWT를 바탕으로 게이트웨이에 접근하여 원하는 API를 호출할 수 있는 흐름이다.

MSA는 비즈니스로직을 처리하기위해 여러 마이크로서비스와 소통하는 과정이 필요할때가 많다.

이 때 여러 마이크로서비스를 지나다니면서 요청한 사용자의 신원에 대한 데이터를 알아야한다면 어떻게할까?

  • 매 마이크로서비스마다 해당 유저를 알아내기 위해 유저 혹은 인증 마이크로서비스에 요청을 보내는 과정을 수행해야할까?
  • 아니면 마이크로서비스간 통신 중 헤더 혹은 요청 본문에 해당 내용을 추가해야할까?

우선 첫 번째 방법은 유저 데이터를 제공할 수 있는 마이크로서비스에 대한 부하가 심해진다.

두 번째 방법은 괜찮아 보인다. 하지만 매 요청마다 중복되고 반복되는 내용을 헤더나 본문에 추가하는 것은 요청 자체를 무겁게 할 뿐더러 확장성도 좋지 않아보인다.

이 개념을 조금 더 발전시킨 내용이 Passport이다.


Passport란

요청한 사용자를 식별하고 그 데이터를 가지고 있는 하나의 토큰이다. 마치 JWT와 유사한 개념이지만 사용처가 다르게 적용된다. 이 특징으로 여러 마이크로서비스에서 요청자의 정보를 조회하기 위해 유저 마이크로서비스를 호출하지 않아도 된다.

또한 Passport는 오직 마이크로서비스간 통신할 때만 사용된다. 즉, 외부로 절대 노출되지 않는 토큰으로 외부 클라이언트에게 제공함으로써 네트워크상에 노출되는 JWT와는 달리 보안성 측면으로도 강점이 있다.


Passport 발급 흐름

  1. 사용자가 JWT로 Gateway에 접근한다.
  2. 게이트웨이에서 JWT의 유효성을 검사한 후 Passport발급을 위한 gRPC 혹은 HTTP Client로 AuthMicroService에 요청한다.
  3. AuthMicroService가 JWT를 통해 추출한 유저 정보로 User 마이크로서비스에 gRPC요청을 보내 실제 유저의 모든 정보를 가져온 후 Passport를 생성한다.
  4. AuthMicroService에서 응답 받은 Passport를 Gateway가 로드밸런싱 전 요청헤더에 삽입한다.
  5. 사용자 요청의 최종 목적지인 PostMicroService에서 비즈니스로직을 처리하는 중 요청자의 정보가 필요하다면 Passport를 Resolve하여 사용할 수 있다.

현재 내 프로젝트에서는 위 플로우로 개선했지만 앞으로는 Passport를 발급하기 위해 Gateway -> AuthMicroService로 요청하는 것이 아니라 Gateway에서 수행할 수 있도록 개선할 예정이다.


Passport 구현 과정

우선 Passport를 정의해야한다. 사용자의 정보를 담을 객체와 무결성 키를 필드로 가지도록했다.

Passport.java

1
2
3
4
5
public record Passport(
        UserInfo userInfo,
        String integrityKey
) {
}

UserInfo.java

UserInfo객체에는 각 마이크로서비스에서 필요하다고 판단된 데이터 필드들을 모아놓았다.

1
2
3
4
5
6
7
8
9
10
11
12
public record UserInfo(
        Long id,
        String email,
        String nickname,
        String username,
        String role,
        Boolean isActivated,
        String accessedAt,
        String createdAt,
        String deletedAt
) {
}

HMACEncoder.java

마이크로서비스 생태계 내부에서만 사용하는 토큰이라 할지라도 암호화 혹은 해싱은 보안의 기본적인 요소이다. 따라서 우리는 PassportHMac을 기반으로 해싱하고 인코딩하여 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RequiredArgsConstructor
public class HMACEncoder {

    private final String HMacAlgorithm;
    private final String passportSecretKey;

    protected String createHMACIntegrityKey(String userInfoString) {
        SecretKeySpec secretKeySpec = new SecretKeySpec(
            passportSecretKey.getBytes(),
            HMacAlgorithm
        );
        Mac mac;
        try {
            mac = Mac.getInstance(HMacAlgorithm);
            mac.init(secretKeySpec);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return Base64.getEncoder()
            .encodeToString(
                mac.doFinal(userInfoString.getBytes())
            );
    }
}

PassportGenerator

위에서 정의한 객체들을 기반으로 실제 Passport를 만드는 부분이다.

  • ObjectMapper를 사용해 UserInfo객체를 Json 문자열로 변환한다.
  • Json 문자열로변환된 UserInfo객체의 내용을 hmacEncoder로 인코딩하여 integrityKey를 생성한다.
  • UserInfoPassport를 생성한다.
  • ObjectMapper를 사용해 Passport객체를 JSON 문자열로 변환한다.
  • Json 문자열로 변환된 Passport를 Base64로 인코딩한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component
@RequiredArgsConstructor
public class PassportGenerator {

    private final ObjectMapper objectMapper;
    private final HMACEncoder hmacEncoder;

    public String generatePassport(UserInfo userInfo) {
        String encodedPassportString;

        try {
            String userInfoString = objectMapper.writeValueAsString(userInfo);
            String integrityKey = hmacEncoder.createHMACIntegrityKey(userInfoString);

            Passport passport = new Passport(userInfo, integrityKey);
            String passportString = objectMapper.writeValueAsString(passport);
            encodedPassportString = Base64.getEncoder().encodeToString(passportString.getBytes());
        } catch (JsonProcessingException e) {
            throw new BaseException(ExceptionType.COMMON_500_000002);
        }

        return encodedPassportString;
    }

}

PassportValidator

Passport가 유효한지 검증하는 객체이다. Passport를 만드는 과정을 반대로 수행하여 올바른 IntegrityKey를 가졌는지 확인한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Component
@RequiredArgsConstructor
public class PassportValidator {

    private static final String USER_INFO = "userInfo";
    private static final String INTEGRITY_KEY = "integrityKey";
    private final ObjectMapper objectMapper;
    private final HMACEncoder hmacEncoder;

    public void validatePassport(String requestedPassport) {
        String encodedUserInfo;
        String integrityKey;

        try {
            String passportStr = new String(
                    Base64.getDecoder().decode(requestedPassport)
            );
            String userInfoString = objectMapper.readTree(passportStr)
                    .get(USER_INFO)
                    .toString();

            userInfoStringintegrityKey = hmacEncoder.createHMACIntegrityKey(userInfoString);
            requestedIntegrityKey = objectMapper.readTree(passportStr)
                    .get(INTEGRITY_KEY)
                    .asText();

            isEqualByRequestedPassport(requestedIntegrityKey, userInfoStringintegrityKey);
        } catch (Exception e) {
            throw new BaseException(ExceptionType.COMMON_500_000002);
        }

    }

    private void isEqualByRequestedPassport(
            String integrityKey,
            String encodedUserInfo
    ) {
        if (!encodedUserInfo.equals(integrityKey)) {
            throw new BaseException(ExceptionType.COMMON_500_000002);
        }
    }
}

RequestHeader에서 Passport를 추출할 수 있고 추출한 Passport를 바탕으로 실제 유저 데이터가 담겨있는 UserInfo로 추출하는 객체이다.

PassportExtractor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Component
@RequiredArgsConstructor
public class PassportExtractor {

    private static final String USER_INFO = "userInfo";
    private static final String AUTHORIZATION_HEADER_NAME = "Authorization";
    private final ObjectMapper objectMapper;
    private final PassportValidator passportValidator;

    public Passport getPassportFromRequestHeader(HttpServletRequest httpServletRequest) {
        try {
            return objectMapper.readValue(
                    new String(
                            Base64.getDecoder().decode(httpServletRequest.getHeader(AUTHORIZATION_HEADER_NAME)),
                            StandardCharsets.UTF_8
                    ),
                    Passport.class
            );
        } catch (JsonProcessingException e) {
            throw new BaseException(ExceptionType.COMMON_500_000002);
        }
    }

    public UserInfo getUserInfoByPassport(Passport passport) {
        try {
            String passportString = new String(
                    Base64.getDecoder().decode(passport.toString())
            );

            passportValidator.validatePassport(passportString);

            String userInfoString = objectMapper.readTree(passportString)
                    .get(USER_INFO)
                    .toString();
            return objectMapper.readValue(
                    userInfoString,
                    UserInfo.class
            );
        } catch (JsonProcessingException e) {
            throw new BaseException(ExceptionType.COMMON_500_000002);
        }
    }
}

Passport 편의 Aspect

매 마이크로서비스에서 Passport를 Resolve하기 위해 PassportExtractor를 의존하는 상황을 개선하고 싶었다.

그래서 Aspect로 이 로직에 대한 횡단 관심사를 분리했다.


InjectEaselAuthentication.java

이 애노테이션으로 Passport를 Extracting할 로직을 대체할 수 있도록 할 것이다.

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface InjectEaselAuthentication {
}

PassportAspect.java

아래와 같은 Aspect를 만들어 @InjectEaselAuthentication애노테이션이 붙은 메서드에서는 ThreadLocal에 즉시 사용할 수 있는 Passport를 보관할 수 있도록 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Aspect
@Component
@RequiredArgsConstructor
public class PassportAspect {

    private final HttpServletRequest httpServletRequest;
    private final PassportExtractor passportExtractor;

    @Around("@annotation(org.palette.aop.InjectEaselAuthentication)")
    public Object setUserInfoByServlet(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        final Passport passport = passportExtractor.getPassportFromRequestHeader(httpServletRequest);
        EaselAuthenticationContext.CONTEXT.set(passport);

        return proceedingJoinPoint.proceed(proceedingJoinPoint.getArgs());
    }
}

EaselAuthenticationContext.java

PassportAspect가 사용하고 있는 ThreadLocal은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
public class EaselAuthenticationContext {
    static final ThreadLocal<Passport> CONTEXT = new ThreadLocal<>();

    public static UserInfo getUserInfo() {
        return CONTEXT.get().userInfo();
    }

    public static String getIntegrityKey() {
        return CONTEXT.get().integrityKey();
    }
}

PassportAspect 사용하기

EaselAuthenticationContext.getUserInfo().id()와 같이 직접 ThreadLocal Context에 접근하여 사용자 정보를 꺼내올 수 있게 되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    @InjectEaselAuthentication
    @PostMapping
    public ResponseEntity<PaintCreateResponse> create(
            @RequestBody PaintCreateRequest paintCreateRequest
    ) {
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(
                        paintUsecase.createPaint(
                                EaselAuthenticationContext.getUserInfo().id(),
                                paintCreateRequest
                        )
                );
    }

부록 - HMAC

HMAC이란

This post is licensed under CC BY 4.0 by the author.