3 minute read

Updated:

👩🏻‍💻 마이크로서비스로 전환하면서 인증 프로세스를 재설계한 과정을 기록하였습니다.

마이크로서비스 전환과 인증 프로세스 고민

모놀리식 서비스를 마이크로서비스로 모듈화하면서 각 모듈이 가져야 할 기능 외에 모든 로직을 제거하였다. 각 모듈간의 관계를 분리하면서 user-service에서 담당했던 인증/인가를 사용할 수 없게 되었다. 해당 시스템은 로그인 이후 모든 API 호출에 대해서는 토큰을 검증하고 있다.

AS-IS 기존 모놀리식 예약 구매 서비스의 인증 절차

  1. 클라이언트에서 api 요청이 들어오면 user-service의 JwtTokenFilter가 JWT 토큰의 유효성을 검사한다.
  2. 토큰 정보가 유효한 경우, DB에서 얻은 사용자 정보를 가지고 인증 정보(authentication)를 설정한다.
  3. 서비스는 전달받은 인증 정보(authentication)를 기반으로 요청을 처리한다.
[PostService.java]
// 게시물을 생성하는 컨트롤러 메서드
// 사용자가 인증되면 인증 정보인 authentication를 전달받는다.
@PostMapping
public Response<Void> create(@RequestBody PostCreateReqeust reqeust, Authentication authentication) {
        // 인증된 사용자의 이름을 얻어와 메서드에 전달한다.
        postService.create(reqeust.getTitle(), reqeust.getContent(), authentication.getName());
        return Response.success();
        }
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    
    private final String key;
    private final UserService userService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        
        final String header = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (header == null || !header.startsWith("Bearer ")) {
            log.error("Error occurs while getting header. header is null or invalid");
            filterChain.doFilter(request, response);
            return;
        }

        try {
            final String token = header.split(" ")[1].trim();

            // JWT 토큰의 유효성을 검사한다.
            if (JwtTokenUtils.isExpired(token, key)) {
                log.error("Key is expired");
                filterChain.doFilter(request, response);
                return;
            };

            // 토큰에서 사용자 이메일을 추출한다.
            String email = JwtTokenUtils.getEmail(token, key);
            // 추출한 이메일을 사용하여 데이터베이스에서 해당 사용자 정보를 조회한다.
            UserAccount userAccount = userService.loadByUserByEmail(email);

            // 사용자 정보로부터 Spring Security의 인증 토큰을 생성한다.
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userAccount, null, null
            );
            // 요청에 대한 인증 정보를 설정한다.
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (RuntimeException e) {
            log.error("Error occurs while validating : {}", e.toString());
            filterChain.doFilter(request, response);
            return;
        }
        
        filterChain.doFilter(request, response);
    }
}

user-serivce의 역할 분리

우선 인증/인가를 계속 user-service에게 위임할 것인가를 고민했다. user-serivce는 회원가입, 로그인, 프로필 조회, 수정 기능만을 주기로 결정했기에 인증은 당연히 user-service의 역할이 되어선 안됐다. 그렇다면 마이크로서비스 구조에서 인증/인가를 구현하는 방법은 무엇일까?

내 프로젝트의 경우 모든 서비스의 요청은 인증 절차를 거쳐야 했기에 서비스 최전방에 위치한 API Gateway를 통해 인증 절차를 위임하는 것이 가장 적합하다고 판단하였다.

Spring Cloud Gateway의 customfilter를 구현해 회원가입/로그인 이후에 발생하는 모든 API 호출은 인증을 거치도록 하였다. user-service는 회원가입도 로그인 시 토큰 발급 기능만 남겨두었다.

TO-BE 마이크로서비스 예약 구매 서비스의 인증 절차

  1. 클라이언트에서 api 요청이 들어오면 API Gateway의 AuthorizationHeaderFilter가 JWT 토큰의 유효성을 검사한다.
  2. 토큰 정보가 유효한 경우, 사용자 ID를 요청 헤더에 추가하여 서비스에 전달한다.
  3. 서비스는 사용자 ID를 기반으로 요청을 처리한다.

구현 과정

1. 의존성 추가

  • JWT를 생성하고 관리하기 위한 라이브러리인 JJWT를 추가한다.
dependencies {
    ...
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    ...
}


2. JWT 관련 유틸리티 클래스 생성

  • JWT 토큰에서 필요한 정보를 추출하고 검증하는 등의 작업을 수행하는 메서드를 구현한다.
public class JwtTokenUtils {

    // 토큰에서 사용자 ID를 추출하는 메서드
    public static String getUserId(String token, String key) {
        return extractClaims(token, key).getSubject();
    }

    // 토큰이 만료되었는지 여부를 확인하는 메서드
    public static boolean isExpired(String token, String key) {
        // 토큰의 만료일을 추출하여 현재 시간과 비교하여 만료 여부를 반환
        Date expiredDate = extractClaims(token, key).getExpiration();
        return expiredDate.before(new Date());
    }

    // 토큰에서 클레임을 추출하는 메서드
    private static Claims extractClaims(String token, String key) {
        // 주어진 키를 사용하여 JWT 토큰을 검증하고, 검증된 페이로드(Claims)를 반환
        return Jwts.parser().verifyWith((SecretKey) getKey(key))
                .build().parseSignedClaims(token).getPayload();
    }

    // 주어진 키로부터 SecretKeySpec 객체를 생성하는 메서드
    private static Key getKey(String key) {
        // 주어진 키를 UTF-8 문자열로 변환하여 SecretKeySpec 객체 생성
        return new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }
}


3. Filter 구현

  • AbstractGatewayFilterFactory를 상속하고, apply 메소드를 구현한다.
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
    @Value("${jwt.secret-key}")
    private String key;
    Environment env;

    public AuthorizationHeaderFilter(Environment env) {
        super(Config.class);
        this.env = env;
    }

    // apply 메서드는 GatewayFilter를 반환하며, 이 필터를 적용하는 데 사용된다.
    @Override
    public GatewayFilter apply(Config config) {
        return ((exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            /// 요청 헤더에 Authorization 헤더가 없는 경우, 401 Unauthorized 오류를 반환한다.
            if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                return onError(exchange, "Error occurs while getting header. header is null", HttpStatus.UNAUTHORIZED);
            }

            // 요청 헤더에서 Authorization 헤더 값을 추출하여 JWT 토큰을 얻는다.
            // JWT 토큰을 파싱하여 사용자 ID를 추출한다.
            String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
            String token = authorizationHeader.split(" ")[1].trim();
            String userId = JwtTokenUtils.getUserId(token, key);

            // JWT 토큰이 만료되었는지 확인한다.
            // 토큰이 만료된 경우, 401 Unauthorized 오류를 반환한다.
            if(JwtTokenUtils.isExpired(token, key)) {
                return onError(exchange, "Key is expired", HttpStatus.UNAUTHORIZED);
            }

            // 요청에 principalId 헤더를 추가하여 사용자 ID를 전달한다.
            request = request.mutate()
                    .header("principalId", userId)
                    .build();

            // 요청 헤더를 로깅한다.
            log.info("RequestHeader: {}", request.getHeaders());

            // 요청을 필터 체인에 전달한다.
            return chain.filter(exchange.mutate()
                    .request(request)
                    .build());
        });
    }

    //Mono, Flux -> Spring WebFlux : 클라이언트 요청이 들어왔을 때 반환해주는 데이터 타입. 단일 값이면 Mono, 다중값이면 Flux 사용
    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);

        log.error(err);
        return response.setComplete();
    }
    
    ...


4.application.yml 구성

  • AuthoriztionHeaderFilter를 추가하여 Filter를 적용할 요청을 지정한다.
...
        # activity-service
        - id: activity-service
          uri: lb://ACTIVITY-SERVICE
          predicates:
            - Path=/activity-service/**
          filters:
            - RewritePath=/activity-service/(?<segment>.*),/api/v1/$\{segment}
            - AuthorizationHeaderFilter
...

마무리

결과적으로 API Gateway에서 공통 인증 절차를 수행함으로써 뒷단의 서비스들은 인증 방식으로부터 완전히 독립되어 인증 서비스와의 의존성이 사라지게 되었고, 토큰 발급 / 인증의 역할 분리도 가능해졌다.


reference

마이크로서비스 구조(MSA)의 인증 및 인가(Authorization & Authentication)