song960530 / foryou-family

0 stars 0 forks source link

게이트웨이 서버에서의 토큰 검증 및 라우팅 #113

Closed song960530 closed 2 years ago

song960530 commented 2 years ago

게이트웨이 서버 구현 내용

아래와 같은 순서로 진행됩니다.

  1. application.yml 구조 설명
  2. Filter 종류 및 역할
  3. Default Filter
  4. Service Filter
  5. excludePath (토큰 정보가 필요없는 URL 모음)

ps. Spring Security를 사용하는 방법도 있지만 직접 Filter를 커스텀 하여 Security와 비슷하게 동작하는 인가 방식을 구현해봤습니다



application.yml 구조 설명



Filter 종류 및 역할



Default Filter

Default Filter인 JwtGlobalFilter는 아래와 같이 구현하였습니다

  1. filter 접근 시 extractMemberId 메서드 에서 토큰 검증 및 추출이 이뤄지며
  2. addHeader 메서드에서 추출한 Subject(MemberId)를 저장합니다
    @Override
    public GatewayFilter apply(Config config) {
        return ((exchange, chain) -> {
            log.info("{} START >>>>>>", config.getBaseMessage());
            log.info("Request URI: {}", exchange.getRequest().getURI());
            log.info("Request Authorization: {}", exchange.getRequest().getHeaders().get(Constants.TOKEN_HEADER_NAME));

            // 1. extractMemberId 에서 토큰 검증 및 추출
            // 2. addHeader로 Subject(MemberId) 저장
            addHeader(exchange, extractMemberId(exchange)); 

            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                log.info("{} END >>>>>>", config.getBaseMessage());
            }));
        });
    }

토큰 검증 및 추출이 이뤄지는 extractMemberId 메서드는 아래와 같이 구현하였습니다

    private String extractMemberId(ServerWebExchange exchange) {
        return Optional.of(jwtTokenProvider.extractToken(exchange))  // Header에서 토큰 추출
                .filter(token -> !token.equals(Constants.DEFAULT_TOKEN_VALUE))
                .map(token -> jwtTokenProvider.extractClaims(token)) // 토큰에서 Claims 추출
                .map(claims -> claims.getSubject()) // Claims에서 Subject(MemberId)추출
                .orElse(Constants.DEFAULT_TOKEN_VALUE)
                ;
    }

Subject(MemberId)를 저장하는 addHeader 메서드는 아래와 같이 구현하였습니다

    private void addHeader(ServerWebExchange exchange, String memberId) {
        exchange.getRequest().mutate()
                .header(Constants.HEADER_MEMBER_NAME, memberId)
                .build();
    }



Service Filter

Service Filter중 하나인 MemberFilter는 아래와 같이 구현하였습니다

  1. Request URL의 Method와 path를 추출합니다
  2. isExcludePath 메서드에서 토큰 정보가 필요없는 URL인지 확인합니다
  3. 만약 토큰 정보가 필요한 URL이라면 isExistMemberIdInPath 메서드에서 Default Filter에서 추출한 Subject(MemberId)가 path에 포함되어있는지 확인합니다
    @Override
    public GatewayFilter apply(Config config) {
        return ((exchange, chain) -> {

            // 1. Method, path 추출
            HttpMethod requestMethod = exchange.getRequest().getMethod();
            String requestPath = exchange.getRequest().getPath().toString();

            // 2. 토큰 정보가 필요없는 URL인지 확인
            if (!GateWayUtils.isExcludePath(config.getExcludePathList(), requestMethod, requestPath)) {
                String memberId = exchange.getRequest().getHeaders().get(Constants.HEADER_MEMBER_NAME).get(0);

                if (Constants.DEFAULT_TOKEN_VALUE.equals(memberId))
                    throw new CustomException(ErrorCode.NOT_EXIST_TOKEN);

                // 3. Header에 저장된 Subject(MemberId)가 path에 포함되어있는지 확인
                if (!GateWayUtils.isExistMemberIdInPath(memberId, requestPath))
                    throw new CustomException(ErrorCode.NOT_MATCHED_MEMBER_ID_TOKEN);
            }

            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            }));
        });
    }

토큰 정보가 필요한 URL인지 확인하는 isExcludePath 메서드는 아래와 같이 구현하였습니다

    public static boolean isExcludePath(List<Map<String, String>> excludePathList, HttpMethod method, String path) {
        return excludePathList.stream()
                .anyMatch(map -> method.matches(map.get("method")) && Pattern.matches(map.get("regExUrl"), path));
    }

Request URL에 Subject(MemberId) 가 포함되어있는지 확인하는 isExistMemberIdInPath 메서드는 아래와 같이 구현하였습니다

    public static boolean isExistMemberIdInPath(String memberId, String path) {
        return Arrays.stream(path.split("/"))
                .anyMatch(splitPath -> splitPath.equals(memberId));
    }



excludePath (토큰 정보가 필요없는 URL 모음)

각 서비스별 설정된 excludePath는 아래와 같이 구현하였습니다

  1. application.yml의 excludePath 설정을 changePathToListMap 메서드에서 Map으로 분리한 뒤 List로 저장합니다 ex) excludePath 설정은 excludePath: POST /member; POST /member/login; 또는 excludePath: POST /auth/**; PATCH /reAuth/**; 와 같이 되어있습니다
...
        private String excludePath;
        private List<Map<String, String>> excludePathList;

        public void setExcludePath(String excludePath) {
            this.excludePath = excludePath;
            // 1. application.yml에 설정한 excludePath을 Map으로 분리하여 List로 저장
            excludePathList = GateWayUtils.changePathToListMap(excludePath); 
        }

changePathToListMap 메서드는 아래와 같이 구현하였습니다

    public static List<Map<String, String>> changePathToListMap(String excludePath) {
        return Arrays.stream(excludePath.replaceAll(" ", "").split(";")) // ";" 구분자로 split
                .filter(path -> !StringUtils.isEmpty(path))
                .map(path -> {
                    Map<String, String> map = new HashMap<>();

                    map.put("method", path.substring(0, path.indexOf("/")).toUpperCase()); // HttpMethod 저장
                    map.put("regExUrl", changePathToRegEx(path)); // 정규식으로 변환하여 저장

                    return map;
                })
                .collect(Collectors.toList());
    }

path를 정규식으로 변환하는 changePathToRegEx는 아래와 같이 구현하였습니다

    private static String changePathToRegEx(String path) {
        String regExPath = Arrays.stream(path.substring(path.indexOf("/")).split("/"))
                .filter(slice -> !StringUtils.isEmpty(slice))
                .map(slice -> {
                    StringBuilder sb = new StringBuilder();

                    sb.append("\\/");
                    sb.append(slice.equals("**") ? "([^\\/]*)" : slice); // "**" 로 작성한 부분 변경

                    return sb.toString();
                })
                .collect(Collectors.joining());

        return "^" + regExPath + "$";
    }