이지선의 블로그
[프로젝트] [리팩토링] MSA 구조를 위한 인증/인가 처리 본문
문제 배경
원래 로그인 시 필자가 담당하는 유저 모듈에서 JWT 토큰을 발급하고 이를 통해 인증을 처리하였으나, 다른 모듈로 요청이 전달될 때 인증 및 인가 정보를 제대로 전달받지 못하는 문제가 발생했다!
이로 인해 다른 모듈에서 기능이 작동되지 않는 상황 ㅠ..
해결 방안
문제를 해결하기 위해 두 가지 방안을 고려하였다 :
- 모든 모듈에서 인증/인가를 구현하여 개별적으로 처리하는 방법
- api-gateway 모듈을 통해 인증/인가를 처리하는 방법
이 중 두 번째 방법을 채택하였는데 그 이유는,
api-gateway 모듈에서 인증/인가를 처리하게 된다면 코드 중복을 방지하고 다른 모듈을 추가하는 상황에서도 따로 인증/인가 로직을 추가할 필요가 없어 유지보수가 쉬워진다는 장점이 있기 때문이다.
즉! 해당 방법이 시스템 성능을 더 향상시킬 수 있음~~
구현 내용
user 모듈
- 로그인 성공 시 user 모듈에서 JWT 토큰을 발급
- 발급 된 JWT 토큰을 응답 헤더에 추가해서 반환
api-gateway 모듈
- api-gateway로 들어온 요청의 헤더에 저장되어있는 토큰을 해석하여 유저 정보를 추출
- 각 요청에 맞는 서비스 모듈로 요청을 재전송할 때 유저의 정보 '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) 회원 정보 수정
@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) 일반 쿠폰 발급
@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 모듈로 모든 요청을 받는다는 점, 다른 모듈로 라우팅하는 방법, 라우팅 하기 전에 필터를 구현해서 로그인 유지를 해결하는 방법 등등 많은 배움이 있었다~~~~
조큼 오래 걸렸지만 다음 프로젝트에서는 조금 더 빠르게 구현 가능하지 아늘까 싶다..ㅠ
'Project > 늘품' 카테고리의 다른 글
[프로젝트] [기능구현] [Redis] 이메일 인증 서비스 (0) | 2024.07.05 |
---|