Project/MSA 프로젝트

[리팩토링] MSA 구조에서의 인증/인가 처리

쉬지마 이굥진 2024. 7. 21. 08:25

<이전 글>

 

[프로젝트][MSA] API Gateway 작성으로 모듈 별 연결하기

우선 지금 진행하고 있는 api gateway와 각 도메인들을 모듈별로 구분해놓고, 각 모듈은 각기 다른 포트 번호를 가지고 있는 구조이다. 오늘은 api gateway 모듈에서 게이트웨이 설정으로 각 모듈 별 ap

developer-jinnie.tistory.com


문제 배경

원래 로그인 시 필자가 담당하는 유저 모듈에서 JWT 토큰을 발급하고 이를 통해 인증을 처리하였으나, 다른 모듈로 요청이 전달될 때 인증 및 인가 정보를 제대로 전달받지 못하는 문제가 발생했다!

이로 인해 유저 정보가 필요한 다른 모듈에서 기능이 작동되지 않는 상황..

 

해결 방안

 

문제를 해결하기 위해서 우리 팀원들이 고려한 두 가지 방안은 이렇다  :

  1. 모든 모듈에서 인증/인가를 구현하여 개별적으로 처리하는 방법
  2. api-gateway 모듈을 통해 인증/인가를 처리하는 방법

이 중 두 번째 방법을 채택하였는데 그 이유는,

api-gateway 모듈에서 인증/인가를 처리하게 된다면 코드 중복을 방지하고 다른 모듈을 추가하는 상황에서도 따로 인증/인가 로직을 추가할 필요가 없어 유지보수가 쉬워진다는 장점이 있기 때문이다.


즉! 해당 방법이 시스템 성능을 더 향상시킬 수도 있고 더 객체지향적인 방법이란 말!

 

구현 내용

user 모듈

  1. 로그인 성공 시 user 모듈에서 JWT 토큰을 발급
  2. 발급 된 JWT 토큰을 응답 헤더에 추가해서 반환

api-gateway 모듈

  1. api-gateway로 들어온 요청의 헤더에 저장되어있는 토큰을 해석하여 유저 정보를 추출
  2. 각 요청에 맞는 서비스 모듈로 요청을 재전송할 때 유저의 정보 'X-USER-ID' 를 헤더에 추가

 

구현 코드

▪️api-gateway 모듈


1. FilterConfig : 라우트 구성
회원가입과 로그인 api를 제외한 모든 요청은 AuthorizationHeaderFilter를 거쳐간다.

@Configuration
public class FilterConfig {

    @Bean
    public AuthorizationHeaderFilter authorizationHeaderFilter(TokenValidator tokenValidator) {
        return new AuthorizationHeaderFilter(tokenValidator);
    }

    @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder, AuthorizationHeaderFilter authorizationHeaderFilter) {
        return builder.routes()
                .route(r -> r.path("/api/v1/users/**")
                        .filters(f -> f.filter(authorizationHeaderFilter.apply(
                                        new AuthorizationHeaderFilter.Config()
                                                .setWhiteList(Arrays.asList("/api/v1/users/signup", "/api/v1/users/login", "/api/v1/users/verification"))))
                                .rewritePath("/api/v1/users/(?<segment>.*)", "/api/v1/users/${segment}"))
                        .uri("http://localhost:8081"))
                .build();
    }
}

 

 

2. AuthorizationHeaderFilter :

- TokenValidator로 토큰 검증

-  토큰에서 유저 아이디 추출

- 추출 된 아이디는 'X-USER-ID'로 헤더에 저장

- 화이트리스트(로그인, 회원가입) 설정

@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {

    private final TokenValidator tokenValidator;

    @Autowired
    public AuthorizationHeaderFilter(TokenValidator tokenValidator) {
        super(Config.class);
        this.tokenValidator = tokenValidator;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            final ServerHttpRequest request = exchange.getRequest();
            String path = request.getURI().getPath();

            if (config.getWhiteList() != null && config.getWhiteList().stream().anyMatch(path::startsWith)) {
                return chain.filter(exchange);
            }

            if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                return handleUnAuthorized(exchange);
            }

            String token = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);

            if (token != null && token.startsWith("Bearer ")) {
                token = token.substring(7);
            }

            try {
                if (!tokenValidator.validateToken(token)) {
                    return handleUnAuthorized(exchange);
                }
                String userId = tokenValidator.getUserId(token);

                ServerHttpRequest modifiedRequest = request.mutate()
                        .header("X-USER-ID", userId)
                        .build();

                return chain.filter(exchange.mutate()
                                .request(modifiedRequest)
                                .build());
            } catch (Exception e) {
                return handleUnAuthorized(exchange);
            }
        };
    }

    private Mono<Void> handleUnAuthorized(ServerWebExchange exchange) {

        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);

        return response.setComplete();
    }

    public static class Config {
        private List<String> whiteList;

        public List<String> getWhiteList() {
            return whiteList;
        }

        public Config setWhiteList(List<String> whiteList) {
            this.whiteList = whiteList;
            return this;
        }
    }
}

 

 

3. TokenValidator : 토큰 검증 로직

@Component
public class TokenValidator {

    @Value("${spring.jwt.secret}")
    private String secretKey;

    private SecretKey key; 

    @PostConstruct
    protected void init() {
        this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    public String getUserId(String token) {
        return parseClaims(token).getSubject();
    }

    public boolean validateToken(String token) {
        Claims claims = parseClaims(token);
        return claims.getExpiration().after(new Date());
    }

    private Claims parseClaims(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build()
                .parseClaimsJws(token)
                .getBody();
    }
}

 

▪️user 모듈

1. 로그인 API 구현

로그인 시 JwtTokenProvider에서 JWT토큰을 발급받아 인증한다.

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserLoginController {

    private final UserLoginService userLoginService;

    @PostMapping("/login")
    public ResponseEntity<JwtTokenDto> login(@RequestBody UserLoginRequestDto userLoginRequestDto) {
        JwtTokenDto jwtTokenDto = userLoginService.login(userLoginRequestDto);
        return ResponseEntity.ok(jwtTokenDto);
    }
}

@Service
@RequiredArgsConstructor
public class UserLoginService {

    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordEncoder passwordEncoder;

    public JwtTokenDto login(UserLoginRequestDto userLoginRequestDto) {
        String email = userLoginRequestDto.email();
        String password = userLoginRequestDto.password();

        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new CustomException(ErrorCode.EMAIL_OR_PASSWORD_INVALID));

        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new CustomException(ErrorCode.EMAIL_OR_PASSWORD_INVALID);
        }

        String accessToken = jwtTokenProvider.generateAccessToken(email, user.getUserId());
        Long expiresTime = jwtTokenProvider.getExpiredTime(refreshToken);

        return new JwtTokenDto(accessToken, refreshToken, expiresTime);
    }
}

 

2.  각 모듈 컨트롤러 코드 수정

헤더에 저장된 'X-USER-ID'를 통해 회원 정보를 알 수 있다.

 

ex1) 회원 정보 수정 API

@PutMapping("/update")
    public ResponseEntity<ApiResponseDto<UserUpdateResponseDto>> update(
            @RequestHeader(value = "X-USER-ID") Long userId,
            @Valid @RequestBody UserUpdateRequestDto userUpdateRequestDto
    ) {
        log.info("UserController - 회원정보 수정 요청: {}", userUpdateRequestDto);
        UserUpdateResponseDto userUpdateResponseDto = userService.update(userId, userUpdateRequestDto);
        return ResponseEntity.ok(new ApiResponseDto<>(HttpStatus.OK, "회원 정보 수정 성공", userUpdateResponseDto));
    }

 

ex2) 일반 쿠폰 발급 API

@PostMapping("/general")
    public ResponseEntity<ApiResponseDto<CouponIssuedResponseDto>> issueCoupon(
            @RequestHeader(value = "X-USER-ID") Long userId, @RequestBody CouponIssuedRequestDto request) {
        CouponIssuedResponseDto issued = couponIssuedService.issueCoupon(userId, request);
        return ResponseEntity.ok(new ApiResponseDto<>(HttpStatus.OK, "쿠폰이 발급되었습니다.", issued));
    }

마치며

이번 리팩토링은 MSA 프로젝트의 구조적 문제를 해결하는 과정이었다.

분리되어있는 모듈을 연결하는 과정!! 이 어려운 과제였다.

 

그래도 이번 리팩토링을 수행하면서, api-gateway 모듈로 모든 요청을 받는다는 점, 다른 모듈로 라우팅하는 방법, 라우팅 하기 전에 필터를 구현해서 로그인 유지를 해결하는 방법 등등 많은 배움이 있었다.

 

팀원들 모두가 같이 고생하고 오래 걸렸지만 다음 프로젝트에서는 조금 더 빠르게 구현 할 수 있을 것 같다!!!