msa에서의 인증/인가
Spring cloud gateway 보안 로직
Gateway에서 우선적으로 처리해 주어야 하는 것은 request를 sanitize하는 것.
sanitize란 클라이언트로부터 올바르지 않은 요청이 올 경우 이를 지워주거나 올바른 값으로 바꿔주는 것을 의미한다.
게이트웨이는 서비스의 진입점이기 때문에, 마이크로서비스 내의 트랜잭션을 구분할 수 있는 TraceID를 생성하여 전파한다.
이렇게 생성한 TID는 각자의 서비스에서 MDC 컨텍스트에 저장되고, 로그를 남길때 이를 같이 남기고 있다.
유저 처리?
기존에는 모든 서비스에서 유저 정보가 필요할 때, 유저 API를 호출하는 방식으로 유저 정보를 갖고오곤 했다.
이렇게 구현을 하게 되면 트랜잭션 내에 불필요한 중복 요청을 유발하고, 서버 리소스의 낭비로 이어졌다.
해결 방법은
-> Passport 구조를 참고한다.
유저 인증 시, Passport라는 ID 토큰을 트랜잭션 내로 전파하는 기술을 사용해야 한다. (프로젝트 별로 Passport를 구현)
Passport란 사용자 기기 정보와 유저 정보를 담은 하나의 토큰이다.
Passport에는 유저 정보가 담겨 있으며, 게이트웨이는 이를 직렬화하여 서비스에 전파한다.
유저 서비스 호출 없이, Passport 정보 사용 만으로 인가가 가능한 것이다.
보안
게이트웨이에 스프링 시큐리티를 적용하면 JWT를 인한 인증이 처리괴고, JWT검증 필터 로직은 아래와 같다.
1. 사용자가 로그인을 하면 유저서비스에서 JWT를 발급한다.
2. 사용자가 로그인 하는 시점에는 JWT가 없기 때문에 게이트웨이에서 JWT를 검증하지 않는다.
3. 사용자는 서버의 API를 호출하기 위해 헤더에 JWT정보를 입력한다.
4. 각각의 마이크로서비스들이 JWT를 검증하는 것이 아닌, 게이트웨이에서 검증을 마치고 검증된 요청만 마이크로서비스에 전달한다.
JWT
클라이언트는 매 요청마다 새로운 토큰을 만들고 이를 Public Gateway에 전달하게 된다.
Public Gateway에서는 해당 토큰의 서명 값, 유효기간, 중복 사용 여부, 만료 여부를 검증하여 Replay Attack이나 토큰 변조를 방지한다.
이렇게 검증된 토큰을 프론트의 Node서버로 전달하고(React) 이걸 가지고 SSR-Gateway를 통해 업스트림 서비스에 접근하게 된다.
이 때, SSR 게이트웨이에서는 토큰을 통해 유저 정보를 가져와 알맞은 업스트림 서비스로 전달하는 역할을 하고 있다.
OAuth2
Gateway는 외부 사에서 토스의 서비스에 접근하는 경우를 위해 OAuth2를 활용한 인증/인가 처리도 지원하고 있다.
허가된 클라이언트가 접근 토큰을 발급받을 수 있도록 미리 식별자와 시크릿 키를 발급해주고, 허가된 클라이언트는 접근 토큰을 요청에 실어 보내게 된다.
Gateway에서는 접근 토큰이 유효한지, 내부의 Oauth서버에게 질의하여 확인한 후 요청한 API가 해당 토큰이 접근할 수 있는 범위의 요청인지 판단하여 서비스로 요청을 전달하게 된다.
Spring Security in User Service
스프링 시큐리티로 유저 서비스에서 토큰 발급과, 로그인 시 인증/인가를 처리하려면 UsernamePasswordAuthenticationFilter와 같은 필터를 상속받아 사용해야 한다. (스프링 시큐리티)
- attemptAuthentication: 로그인 요청이 들어오면 상위 클래스의 getAuthenticationManager()를 호출하여 사용자가 전달한 파라미터를 전달하여 로그인을 시도한다.
- successfulAuthentication: 로그인이 성공하면 DB에서 사용자의 userId를 조회하여 JWT를 생성하고 Response의 헤더에 넣는 역할을 한다.
@Slf4j
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final MyUserService userService;
private final Environment environment;
public AuthenticationFilter(AuthenticationManager authenticationManager,
MyUserService userService,
Environment environment) {
super(authenticationManager);
this.userService = userService;
this.environment = environment;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequest loginRequest = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getEmail(),
loginRequest.getPassword(),
Collections.emptyList()
)
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
String userName = ((User) authResult.getPrincipal()).getUsername();
MyUserDto userDto = userService.getUserDetailsByEmail(userName);
String token = Jwts.builder()
.setSubject(userDto.getUserId())
.setExpiration(new Date(System.currentTimeMillis() + Long.parseLong(Objects.requireNonNull(environment.getProperty("token.expiration_time")))))
.signWith(SignatureAlgorithm.HS512, environment.getProperty("token.secret"))
.compact();
response.addHeader("token", token);
response.addHeader("userId", userDto.getUserId());
}
}
Spring Security in Gateway
게이트웨이에서 사용자의 헤더의 토큰을 통하여 인증처리를 진행하는 클래스를 추가한다.
- apply: 사용자의 헤더에 Authorization 값이 없거나 유효한 토큰이 아니라면 사용자에게 권한이 없다는 401 Unauthorized 코드를 반환한다.
- isJwtValid: JWT를 파싱하여 유효한 토큰인지 확인한다. 여기서 사용되는 token.secret는 다음 단계에서 입력하겠지만 유저 서비스에서 사용하는 토큰과 동일하다.
@Slf4j
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private final Environment environment;
public AuthorizationHeaderFilter(Environment environment) {
super(Config.class);
this.environment = environment;
}
@Override
public GatewayFilter apply(AuthorizationHeaderFilter.Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace("Bearer", "");
if (!isJwtValid(jwt)) return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
return chain.filter(exchange);
};
}
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(err);
return response.setComplete();
}
private boolean isJwtValid(String jwt) {
String subject = null;
try {
subject = Jwts.parser().setSigningKey(environment.getProperty("token.secret"))
.parseClaimsJws(jwt).getBody()
.getSubject();
} catch (Exception ex) {
ex.printStackTrace();
}
return !Strings.isBlank(subject);
}
public static class Config {}
}
암호화를 위한 시큐리티, 게이트웨이의 라우팅 방법 등은 기술하지 않았다.