[API Gateway + Refresh JWT 인증서버 구축하기] Spring boot + Spring Cloud Gateway + Redis + mysql JPA 3편

Sieun Sim·2020년 5월 31일
2

서버개발캠프

목록 보기
15/21

ErrorCode 정의

  • 예상된 에러 안에 들어오면 성공이든 실패든 httpStatus는 200 OK
  • errorCode는 모든 요청에 대한 응답에 json값중 하나로 넣을 값
  • 성공 시 errorCode는 10, frontend에서 10인지 먼저 체크하기를 기대

인증서버

Error handling: @RestControllerAdvice

@RestControllerAdvice
public class AdviceController {
    private Logger logger = LoggerFactory.getLogger(ApplicationRunner.class);

    @ExceptionHandler(DataIntegrityViolationException.class)
    public Map<String, Object> duplicateEx(Exception e) {
        logger.warn("DataIntegrityViolationException" + e.getClass());
        Map<String, Object> map = new HashMap<>();
        map.put("errorCode", 53);
        return map;
    }

    @ExceptionHandler(BadCredentialsException.class)
    public Map<String, Object> badCredentialEx(Exception e) {
        logger.warn("BadCredentialsException");
        Map<String, Object> map = new HashMap<>();
        map.put("errorCode", 63);
        return map;
    }

    @ExceptionHandler({
            IllegalArgumentException.class, MissingServletRequestParameterException.class})
    public Map<String, Object> paramsEx(Exception e) {
        logger.warn("params ex: "+ e);
        Map<String, Object> map = new HashMap<>();
        map.put("errorCode", 51);
        return map;
    }

    @ExceptionHandler(NullPointerException.class)
    public Map<String, Object> nullEx(Exception e) {
        logger.warn("null ex" + e.getClass());
        Map<String, Object> map = new HashMap<>();
        map.put("errorCode", 61);
        return map;
    }

}

@RestControllerAdvice@ExceptionHandler 를 이용해 전역적인 에러 핸들링을 할 수 있다. 발견되는 에러들을 모두 잡아 넣어 감옥(?)처럼 만들고 있다. 중복되는 코드들이 사라지고 훨씬 보기 좋아졌다.

SQLIntegrityConstraintViolationException catch하기

@ExceptionHandler에서 해당 오류를 잡지 못했는데 spring-data JPA를 사용하고 있으므로SQLException 대신에 DataIntegrityViolationException 을 잡아야 한다.

Login controller

로그인을 하면 spring security의 userDetails를 이용해 User DB에 접근해 유저 정보를 확인한다. 여기서 아이디나 비밀번호가 틀리면 exceptionhandler에서 bad credential로 처리한다.

@PostMapping(path = "/auth/login")
public Map<String, Object> login(@RequestBody Map<String, String> m) throws Exception {
    Map<String, Object> map = new HashMap<>();
    final String username = m.get("username");
    logger.info("test input username: " + username);

    am.authenticate(new UsernamePasswordAuthenticationToken(username, m.get("password")));

    final UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    final String accessToken = jwtGenerator.generateAccessToken(userDetails);
    final String refreshToken = jwtGenerator.generateRefreshToken(username);

    Token retok = new Token();
    retok.setUsername(username);
    retok.setRefreshToken(refreshToken);

    //generate Token and save in redis
    ValueOperations<String, Object> vop = redisTemplate.opsForValue();
    vop.set(username, retok);

    logger.info("generated access token: " + accessToken);
    logger.info("generated refresh token: " + refreshToken);
    map.put("errorCode", 10);
    map.put("accessToken", accessToken);
    map.put("refreshToken", refreshToken);
    return map;
}

Refresh token controller

Post로 직접 토큰을 받기로 했다. Gateway에서 access token이 만료됐다는 응답을 보내면 클라이언트에서 알아서 /auth/refresh로 access token과 refresh token을 같이 보낸다. expired access token을 파싱하기 위해 ExpiredJwtException 을 일으키고 error 객체에서 e.getClaims().getSubject() 정보를 얻어올 수 있다. 이렇게 받은 username을 이용해 "username":refreshtoken 형태로 저장되어있던 redis에서 refresh token을 받아온다. 유저가 post body로 보낸 refresh token과 비교해 동일하고 만료되지 않았다면 응답으로 access token을 새로 발급한다.

@PostMapping(path="/auth/refresh")
public Map<String, Object>  requestForNewAccessToken(@RequestBody Map<String, String> m) {
    String username = null;
    Map<String, Object> map = new HashMap<>();
    String expiredAccessToken = m.get("accessToken");
    String refreshToken = m.get("refreshToken");
    logger.info("get expired access token: " + expiredAccessToken);

    try {
        username = jwtGenerator.getUsernameFromToken(expiredAccessToken);
    } catch (ExpiredJwtException e) {
        username = e.getClaims().getSubject();
        logger.info("username from expired access token: " + username);
    }
    if (username == null) throw new IllegalArgumentException();

    ValueOperations<String, Object> vop = redisTemplate.opsForValue();
    Token result = (Token) vop.get(username);
    String refreshTokenFromDb = result.getRefreshToken();
    logger.info("rtfrom db: " + refreshTokenFromDb);

    //user refresh token doesnt match with cache
    if (!refreshToken.equals(refreshTokenFromDb)) {
        map.put("errorCode", 58);
        return map;
    }

    //refresh token is expired
    if (jwtGenerator.isTokenExpired(refreshToken)) {
        map.put("errorCode", 57);
    }

    //generate access token if valid refresh token
    final UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    String newAccessToken =  jwtGenerator.generateAccessToken(userDetails);
    map.put("errorCode", 10);
    map.put("accessToken", newAccessToken);
    return map;
}

Token generator code

public String generateAccessToken(UserDetails userDetails) {
    Map<String, Object> claims = new HashMap<>();
    List<String> li = new ArrayList<>();
    for (GrantedAuthority a: userDetails.getAuthorities()) {
        li.add(a.getAuthority());
    }
    claims.put("role",li);
    return Jwts.builder().setClaims(claims).setSubject(userDetails.getUsername()).setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + JWT_ACCESS_TOKEN_VALIDITY * 1000))
            .signWith(SignatureAlgorithm.HS512, secret).compact();
}

public String generateRefreshToken(String username) {
    return Jwts.builder().setSubject(username).setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + JWT_REFRESH_TOKEN_VALIDITY * 1000))
            .signWith(SignatureAlgorithm.HS512, secret).compact();
}     

Gateway의 filter에서 JWT validate하기

클라이언트의 요청을 게이트웨이에서 각 기능 서버들에게 보내주기 전에, 게이트웨이 차원에서 구현한 필터에서 JWT의 유효성을 먼저 검사하고 유효하지 않다면 바로 에러를 반환하고 싶다.

다른 filter 거치지 않고 skip하기

return exchange.getResponse().setComplete();

ServerWebExchange에서 ServerHttpResponse를 받아와 강제로 완료시켜버리면 된다. exchange.getResponse() 를 통해 ServerHttpResponse 객체를 받아와 Http Status와 header는 바꾸고 바로 setComplete()를 불러와 다른 필터로 가기 전에 필터에서 끝내버릴 수도 있다.

ServerHttpResponse body를 쓸 수 없는 문제

하지만 response body를 직접 바꿀 수는 없다. 정말 예상치 못한 에러를 제외하고는 HttpStatus로 200 OK를 주고 자체 errorCode를 body로 항상 주기로 했기 때문에 setComplete()를 사용할 수 없었다. buffer에 직접 쓰는 복잡한 방법을 찾아볼 수 있으나 잘 작동하지 않았고 별로 권장되지 않는 방법인 듯 하여 Error handling으로 노선을 바꿨다.

Gateway Filter에서의 Error handling

Spring Cloud Gateway는 MVC의 Controller를 사용하지 않으므로 @RestControllerAdvice@ExceptionHandler 를 사용할 수 없다. 대신에 webflux에서 사용할 수 있는 ErrorWebExceptionHandler 를 사용할 수 있다.

ErrorWebExceotionHandler를 반환할 핸들러 메소드를 Bean으로 등록해주고, implements 해서 사용한다.

@Bean
    public ErrorWebExceptionHandler myExceptionHandler() {
        return new MyWebExceptionHandler();
    }

에러 핸들러로 넘어가면 filter를 거치기를 멈추고 바로 에러 핸들러로 간다고 한다. 그래서 여기서 ServerHttpResponse 객체에writewith()를 사용해 body를 직접 적어줄 수 있다. 이걸 Error handler를 부르지 않고 해보고 싶긴 한데 불가능한 듯 하고 exception에 따라 따로 핸들링할 수 있어 나쁘지 않은 방법같다. exception에 따라 json string을 만들어 body에 errorCode를 작성해주었다.

public class MyWebExceptionHandler implements ErrorWebExceptionHandler {
    private String errorCodeMaker(int errorCode) {
        return "{\"errorCode\":" + errorCode +"}";
    }

    @Override
    public Mono<Void> handle(
            ServerWebExchange exchange, Throwable ex) {
        logger.warn("in GATEWAY Exeptionhandler : " + ex);
        int errorCode = 999;
        if (ex.getClass() == NullPointerException.class) {
            errorCode = 61;
        } else if (ex.getClass() == ExpiredJwtException.class) {
            errorCode = 56;
        } else if (ex.getClass() == MalformedJwtException.class || ex.getClass() == SignatureException.class || ex.getClass() == UnsupportedJwtException.class) {
            errorCode = 55;
        } else if (ex.getClass() == IllegalArgumentException.class) {
            errorCode = 51;
        }

        byte[] bytes = errorCodeMaker(errorCode).getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
        return exchange.getResponse().writeWith(Flux.just(buffer));
    }
}

public reactor.core.publisher.Mono<Void> writeWith(org.reactivestreams.Publisher<? extends DataBuffer> body)

Use the given Publisher to write the body of the message to the underlying HTTP layer.

Databuffer.wrap(byte[] bytes)

Wrap the given byte array in a DataBuffer. Unlike allocating, wrapping does not use new memory.

Flux.just(DataBuffer)?

Flux는 발행자(publisher)로서 누군가 구독(subscribe)하면 데이터를 뱉는다. 이에 대한 설명을 가져왔다


구독자들(Subscribers)

데이터가 흘러가게(flow)하기 위해, subscribe() 메소드들 중 하나를 이용하여 Flux에 대해 구독해야 한다. 이 메소드들만이 데이터가 흘러가게 할 수 있다. subscribe 메소드들은 시퀀스에 대해 정의했던 오퍼레이터의 연쇄(chain)를 거슬러 올라가서 배포자에게 데이터의 생성을 시작할 것을 요청한다.지금까지 작업했던 예제를 예로 들면, 내재돼 있는 문자열 컬랙션이 반복처리(iterated)된다. 좀 더 복잡한 사용 예를 예로 든다면, 파일시스템으로부터 파일을 읽도록 할 수도 있고 데이터베이스로부터 조회할 수도 있으며, HTTP 서비스를 호출할 수도 있다.

subscribe() 메소드들 실제로 호출하는 예는 다음과 같다.

subscribing Flux Sequence

Flux.just("red", "white", "blue")
  .log()
  .map(String::toUpperCase)
.subscribe();

log

09:17:59.665 [main] INFO reactor.core.publisher.FluxLog -  onSubscribe(reactor.core.publisher.FluxIterable$IterableSubscription@3ffc5af1)
09:17:59.666 [main] INFO reactor.core.publisher.FluxLog -  request(unbounded)
09:17:59.666 [main] INFO reactor.core.publisher.FluxLog -  onNext(red)
09:17:59.667 [main] INFO reactor.core.publisher.FluxLog -  onNext(white)
09:17:59.667 [main] INFO reactor.core.publisher.FluxLog -  onNext(blue)
09:17:59.667 [main] INFO reactor.core.publisher.FluxLog -  onComplete()

Gateway Filter Full Code

  @Component
  public class JwtRequestFilter extends
          AbstractGatewayFilterFactory<JwtRequestFilter.Config> implements Ordered {

      final Logger logger =
              LoggerFactory.getLogger(JwtRequestFilter.class);

      @Autowired
      private JwtValidator jwtValidator;

      @Override
      public int getOrder() {
          return -2; // -1 is response write filter, must be called before that
      }

      public static class Config {
          private String role;
          public Config(String role) {
              this.role = role;
          }
          public String getRole() {
              return role;
          }
      }

        @Bean
        public ErrorWebExceptionHandler myExceptionHandler() {
            return new MyWebExceptionHandler();
        }

        public class MyWebExceptionHandler implements ErrorWebExceptionHandler {
            private String errorCodeMaker(int errorCode) {
                return "{\"errorCode\":" + errorCode +"}";
            }

            @Override
            public Mono<Void> handle(
                    ServerWebExchange exchange, Throwable ex) {
                logger.warn("in GATEWAY Exeptionhandler : " + ex);
                int errorCode = 999;
                if (ex.getClass() == NullPointerException.class) {
                    errorCode = 61;
                } else if (ex.getClass() == ExpiredJwtException.class) {
                    errorCode = 56;
                } else if (ex.getClass() == MalformedJwtException.class || ex.getClass() == SignatureException.class || ex.getClass() == UnsupportedJwtException.class) {
                    errorCode = 55;
                } else if (ex.getClass() == IllegalArgumentException.class) {
                    errorCode = 51;
                }

                byte[] bytes = errorCodeMaker(errorCode).getBytes(StandardCharsets.UTF_8);
                DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
                return exchange.getResponse().writeWith(Flux.just(buffer));
            }
        }


        public JwtRequestFilter() {
            super(Config.class);
        }
       // public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        @Override
        public GatewayFilter apply(Config config) {
            return (exchange, chain) -> {
                String token = exchange.getRequest().getHeaders().get("Authorization").get(0).substring(7);
                logger.info("token : " + token);
                Map<String, Object> userInfo = jwtValidator.getUserParseInfo(token);
                ArrayList<String> arr = (ArrayList<String>)userInfo.get("role");
                if ( !arr.contains(config.getRole())) {
                    throw new IllegalArgumentException();
                }
                return chain.filter(exchange);
            };
        }
    }

자바에서 override 한 method에 custom Exception Class를 적용할 수 없는 이유

override한 method의 정해진 시나리오대로 처리해야되기 때문에 마음대로 exception을 사용할 수는 없고,

IllegalArgumentException 이나 NullPointerException 같은 RuntimeException을 사용해야 한다.

이미 git repository인 폴더를 다른 git repository에 포함하고 싶을 때

마스터 directory에서 전체를 push하면 git이 그 폴더를 submodule로 생각하고 github에 회색 폴더로 올려 클릭할수 없게 만들어 버린다. 해결 방법은 해당 폴더 안에서 git 설정을 지워버리고, 마스터 directory에서 해당 폴더에 대해 cache된 정보도 지워준 뒤 다시 add 하는 것이다.

cd <offending git submodule>
rm -rf .git
git rm --cached <offending git submodule>

참고자료

Spring Boot 전역 에러 처리

[Spring Webflux] 2. Mono and Flux

Conclusions

1개의 댓글

comment-user-thumbnail
2020년 10월 8일
//refresh token is expired
if (jwtGenerator.isTokenExpired(refreshToken)) {
    map.put("errorCode", 57);
}

이 부분 return map 안한게 의도하신건지 궁금합니다

답글 달기