@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
을 잡아야 한다.
로그인을 하면 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;
}
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();
}
클라이언트의 요청을 게이트웨이에서 각 기능 서버들에게 보내주기 전에, 게이트웨이 차원에서 구현한 필터에서 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으로 노선을 바꿨다.
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>