Client ‐ Front ‐ Gateway ‐ UserService 인증 인가 흐름 - nhnacademy-be10-WannaB/wannab-wiki GitHub Wiki

- 로그인 시, 전체 흐름 -


1. Client가 웹 브라우저에서 POST /login 요청을 보낸다.

  • 사용자가 웹 브라우저(크롬)등을 이용해 POST /login 요청을 통해서 로그인 요청을 한다.
  • wannab-front는 /login 요청이 오면 서비스 로직에서 Feign Client을 이용해서 /user-service/login 으로 로그인 요청을 한다.

2. Gateway는 /user-service 로 시작하는 요청을 캐치하니까, 해당요청은 무조건 Gateway로 오게 된다.

  • Gateway의 필터에서는 /login 요청은 화이트리스트로 등록해 그냥 통과시킨다.
    • 인증/인가를 진행하지 않음
  • 이후, Gateway는 /user-service로 오는 요청이므로 유저 서비스로 요청을 라우팅해준다.

3. 유저 서비스는 해당 요청을 보고 올바르다면 jwt 토큰을 발급해준다.

  • 이때, 헤더로 발급을 해줌
  • 역으로 유저서비스 → 게이트웨이 → 프론트 서버로 오게 됨

4. 프론트 서비스는 응답의 헤더를 보고 jwt token을 쿠키로 구워서 붙인다.

- 로그인 이후, 인증이 필요한 요청 (유효할때) -


1. Client가 웹 브라우저를 통해서 GET /{order-id} 등의 요청을 보낸다.

  • 사용자가 요청을 보낸다
  • wannab-front는 요청이 오면, 쿠키의 값을 읽고 헤더로 바꿔서 요청에 붙인다.
  • 이 요청은 게이트웨이를 통과하게 됨

2. Gateway는 요청을 캐치하고, 필터로 검증을 진행한다.

  • 화이트리스트에 등록되지 않았으므로 jwt 토큰이 유효한지 검토한다
  • 필터에서는 헤더를 보고 jwt 검증을 진행한다.
  • 유효한 토큰이라면 통과한다.

3. 유효한 요청이므로 나머지는 역순 진행

- 로그인 이후, 인증이 필요한 요청 (유효하지 않을 때) -


1, 2번 위와 동

3. 유효하지 않으므로 프론트에 401 던지면 끝

- 로그인 이후, 인증이 필요한 요청 (accessToken 재발급) -


1, 2번 위와 동

3. 유효하지 않으므로 프론트에 401 던지면 끝

  • 이때, 유효하지 않으면 게이트웨이에서 유저서비스에 요청을 보내서 accessToken 토큰을 재발급받아야하는데 문제는 게이트웨이는 Netty기반의 비동기 서버이기 때문에 Feign Client를 사용하기 힘들기도 하고, 매우 어색함
  • 그렇다고, 무지성 401을 던지고 프론트에서 다시 요청해! 라고 하면

이전의 사용자 요청의 흐름을 이어나갈 수 없다는 문제가 있음!!

  • 그래서 프론트 서버에 필터를 하나 만들고 그 필터가 모든 요청이 나갈때 jwt가 유효한지 체크한다음, 만료된 상황이고, refresh token이 유효하다면 유저서비스에 accesstoken 재발급 요청을 보내도록 함
  • 그러면 프론트 서버는 accessToken 을 받고 이전의 요청 흐름을 자연스럽게 이어나갈 수 있음

그렇다면, 필터는 프론트에만 있어도 되는가?

  • 그러게?
  • 아 어차피 다운스트림으로 헤더를 다 전달해주어야하니까 게이트웨이도 있어야하고
  • 브라우저가 아닌 다른 API요청이 게이트웨이로 올수도있다…
    • 물론 우리는 게이트웨이를 외부에 공개하지 않아서 괜찮긴함

Gateway JWT 인증 필터 예시

@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);
    }
}
  • 프론트의 필터임

MutableHttpServletRequest

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

⚠️ **GitHub.com Fallback** ⚠️