MSA에서 API Gateway를 활용한 인증 프로세스 재설계
Updated:
👩🏻💻 마이크로서비스로 전환하면서 인증 프로세스를 재설계한 과정을 기록하였습니다.
마이크로서비스 전환과 인증 프로세스 고민
모놀리식 서비스를 마이크로서비스로 모듈화하면서 각 모듈이 가져야 할 기능 외에 모든 로직을 제거하였다. 각 모듈간의 관계를 분리하면서 user-service
에서 담당했던 인증/인가를 사용할 수 없게 되었다. 해당 시스템은 로그인 이후 모든 API 호출에 대해서는 토큰을 검증하고 있다.
AS-IS 기존 모놀리식 예약 구매 서비스의 인증 절차
- 클라이언트에서 api 요청이 들어오면 user-service의 JwtTokenFilter가 JWT 토큰의 유효성을 검사한다.
- 토큰 정보가 유효한 경우, DB에서 얻은 사용자 정보를 가지고 인증 정보(authentication)를 설정한다.
- 서비스는 전달받은 인증 정보(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 마이크로서비스 예약 구매 서비스의 인증 절차
- 클라이언트에서 api 요청이 들어오면 API Gateway의 AuthorizationHeaderFilter가 JWT 토큰의 유효성을 검사한다.
- 토큰 정보가 유효한 경우, 사용자 ID를 요청 헤더에 추가하여 서비스에 전달한다.
- 서비스는 사용자 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에서 공통 인증 절차를 수행함으로써 뒷단의 서비스들은 인증 방식으로부터 완전히 독립되어 인증 서비스와의 의존성이 사라지게 되었고, 토큰 발급 / 인증의 역할 분리도 가능해졌다.