Client ‐ Front ‐ Gateway ‐ UserService 인증 인가 흐름 - nhnacademy-be10-WannaB/wannab-wiki GitHub Wiki
- 사용자가 웹 브라우저(크롬)등을 이용해 POST
/login
요청을 통해서 로그인 요청을 한다. - wannab-front는
/login
요청이 오면 서비스 로직에서 Feign Client을 이용해서/user-service/login
으로 로그인 요청을 한다.
- Gateway의 필터에서는
/login
요청은 화이트리스트로 등록해 그냥 통과시킨다.- 인증/인가를 진행하지 않음
- 이후, Gateway는
/user-service
로 오는 요청이므로 유저 서비스로 요청을 라우팅해준다.
- 이때, 헤더로 발급을 해줌
- 역으로 유저서비스 → 게이트웨이 → 프론트 서버로 오게 됨
- 사용자가 요청을 보낸다
- wannab-front는 요청이 오면, 쿠키의 값을 읽고 헤더로 바꿔서 요청에 붙인다.
- 이 요청은 게이트웨이를 통과하게 됨
- 화이트리스트에 등록되지 않았으므로 jwt 토큰이 유효한지 검토한다
- 필터에서는 헤더를 보고 jwt 검증을 진행한다.
- 유효한 토큰이라면 통과한다.
- 이때, 유효하지 않으면 게이트웨이에서 유저서비스에 요청을 보내서 accessToken 토큰을 재발급받아야하는데 문제는 게이트웨이는 Netty기반의 비동기 서버이기 때문에 Feign Client를 사용하기 힘들기도 하고, 매우 어색함
- 그렇다고, 무지성 401을 던지고 프론트에서 다시 요청해! 라고 하면
- 그래서 프론트 서버에 필터를 하나 만들고 그 필터가 모든 요청이 나갈때 jwt가 유효한지 체크한다음, 만료된 상황이고, refresh token이 유효하다면 유저서비스에 accesstoken 재발급 요청을 보내도록 함
- 그러면 프론트 서버는 accessToken 을 받고 이전의 요청 흐름을 자연스럽게 이어나갈 수 있음
- 그러게?
- 아 어차피 다운스트림으로 헤더를 다 전달해주어야하니까 게이트웨이도 있어야하고
- 브라우저가 아닌 다른 API요청이 게이트웨이로 올수도있다…
- 물론 우리는 게이트웨이를 외부에 공개하지 않아서 괜찮긴함
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthorizationCookieFilter implements GlobalFilter, Ordered {
@Value("${jwt.secret-key}")
private String secretKey;
private final List<String> whitelist = List.of(
"/user-service/api/auth/login",
"/user-service/api/auth/signup",
"/user-service/api/auth/refresh"
);
private final Predicate<ServerHttpRequest> isSecured = request ->
whitelist.stream().noneMatch(path -> request.getURI().getPath().startsWith(path));
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// pass
if (!isSecured.test(request)) {
log.debug("PASS");
return chain.filter(exchange);
}
// 헤더 기반
String authHeader = request.getHeaders().getFirst("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return this.onError(exchange, "Authorization header missing or malformed", HttpStatus.UNAUTHORIZED);
}
// Bearer => 띄어쓰기까지 7자
String token = authHeader.substring(7);
if (isInvalidToken(token)) {
return this.onError(exchange, "JWT token invalid", HttpStatus.UNAUTHORIZED);
}
// 쿠키 기반
// HttpCookie jwtCookie = request.getCookies().getFirst("access_token");
// if (jwtCookie == null) {
// return this.onError(exchange, "JWT cookie missing", HttpStatus.UNAUTHORIZED);
// }
//
// String token = jwtCookie.getValue();
// if (isInvalidToken(token)) {
// return this.onError(exchange, "JWT token invalid", HttpStatus.UNAUTHORIZED);
// }
Claims claims = parseClaims(token);
String userId = claims.getSubject();
ServerHttpRequest mutatedRequest = request.mutate()
.header("X-USER-ID", userId)
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
private boolean isInvalidToken(String token) {
try {
parseClaims(token);
return false;
} catch (ExpiredJwtException e) {
log.warn("Expired JWT: {}", e.getMessage());
} catch (JwtException e) {
log.warn("Invalid JWT: {}", e.getMessage());
}
return true;
}
private Claims parseClaims(String token) {
Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
return Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
private Mono<Void> onError(ServerWebExchange exchange, String message, HttpStatus status) {
log.warn("Authorization error: {}", message);
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(status);
return response.setComplete();
}
@Override
public int getOrder() {
return -1;
}
}
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final RefreshTokenClient refreshTokenClient; // Feign 또는 RestTemplate 등
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String accessToken = extractToken(request);
if (accessToken != null && jwtProvider.isExpired(accessToken)) {
String refreshToken = extractRefreshTokenFromCookie(request);
// 리프레시 요청
String newAccessToken = refreshTokenClient.refresh(refreshToken);
if (newAccessToken != null) {
// 새 AccessToken을 Authorization 헤더에 삽입 (mutable request wrapper 사용 필요)
MutableHttpServletRequest mutableRequest = new MutableHttpServletRequest(request);
mutableRequest.putHeader("Authorization", "Bearer " + newAccessToken);
filterChain.doFilter(mutableRequest, response);
return;
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token expired and refresh failed");
return;
}
}
// 유효하거나 토큰 없음 → 그대로 진행
filterChain.doFilter(request, response);
}
}
- 프론트의 필터임
public class MutableHttpServletRequest extends HttpServletRequestWrapper {
private final Map<String, String> customHeaders = new HashMap<>();
public MutableHttpServletRequest(HttpServletRequest request) {
super(request);
}
public void putHeader(String name, String value) {
this.customHeaders.put(name, value);
}
@Override
public String getHeader(String name) {
return customHeaders.getOrDefault(name, super.getHeader(name));
}
@Override
public Enumeration<String> getHeaderNames() {
Set<String> names = new HashSet<>(customHeaders.keySet());
Enumeration<String> originalNames = super.getHeaderNames();
while (originalNames.hasMoreElements()) {
names.add(originalNames.nextElement());
}
return Collections.enumeration(names);
}
}
- 서블릿 API로는 헤더를 수정할 수 없어서 한번 래핑을 해주어야함
- 굉장히 설명이 잘되어있음
[Java Spring JWT with refresh token workflow questions](https://stackoverflow.com/questions/53220918/java-spring-jwt-with-refresh-token-workflow-questions?utm_source=chatgpt.com)
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJST0xFX1VTRVIiLCJpYXQiOjE3NTA2NDQ2MTcsImV4cCI6MTc1MDY0NDYxN30.UtEa9EqeGwjPCTiT38AC7OTmY96SXUXsqa2liSLWxvE