MSA 구현하기(6) -API Gateway에서 사용자 요청 인증/인가하기 (feat. JWT, MSA)
Skala 과정에서 마이크로 서비스 아키텍처 구조에 대해 새롭게 알게 되었습니다.
배운 것을 실제로 구현해보기 위해 이 여정을 시작하기로 했습니다.
처음 글부터 보러가기
이번 기능은 생각(GraphQL을 포기하지 못했던 때)보다 잘 작동해서
다시 추진력을 얻었습니다,, 바뀐 구성 따라잡기
System Architecture
이제 진짜진짜진짜 최최최종 시스템 구조입니다…🥲
Auth Server를 따로 구성하기에는 간단하게 구현하려다가 일이 커질 것만 같아서
기능 구현 단계에서 최종 목표인 Aggregation을 하루 빨리 진행하기 위해
회원 가입과 로그인 기능을 같이 두기로 했습니다! (분리를 하는 서비스도 분명 있긴하겠죠..??)
User Server
UserController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestController
@Slf4j
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<List<UserInfoResponse>> getAllUsers() {
return ResponseEntity.ok(
userService.getAll()
);
}
@GetMapping("/{userId}")
public ResponseEntity<UserInfoResponse> getUserById(@PathVariable("userId") Long userId) {
return ResponseEntity.ok(userService.getUser(userId));
}
@PostMapping("/register")
public ResponseEntity<UserInfoResponse> createUser(@RequestBody RegisterUserRequest request) {
log.info(request.getUsername());
log.info(request.getPassword());
log.info(request.getEmail());
log.info(request.getPhone());
return ResponseEntity.ok(userService.register(request));
}
}
DTO 는 각자 설정에 맞게 구성해주시면 됩니다
AuthController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RestController
@Slf4j
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
@RequestBody @Validated LoginRequest request
) {
return ResponseEntity.ok(
authService.login(request)
);
}
@GetMapping("/validation")
public ResponseEntity<ValidTokenResponse> validate(
JwtAuthentication auth
) {
return ResponseEntity.ok(
authService.validToken(auth.getUserId())
);
}
}
ValidTokenResponse 는 전달된 JWT Token을 발급받은 사용자의 정보를 담고 있습니다!
사용자의 권한에 따라 접근할 수 있는 url을 달리 할 것이기 때문에 사용자의 역할(권한)에 대한 정보도 담겨있습니다
JWT 설정에 관련된 건 여기를 참고해주세요 ^,^
API Gateway Server
WebClientConfig.java
1
2
3
4
5
6
7
8
9
@Configuration
public class WebClientConfig {
@Bean
@LoadBalanced // ✅ LoadBalancer 활성화
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
}
Eureka Client로 등록된 이름으로 webClient Url을 작성하기 위해 필요한 클래스입니다
CustomPreFilter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
@Slf4j
public class CustomPreFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest response = exchange.getRequest();
log.info("Pre Filter: Request URI is {}", response.getURI());
// Add any custom logic here
return chain.filter(exchange);
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}
Global 하게 작용할 PreFilter를 커스텀하기 위해 필요한 클래스입니다.
당장 사용하진 않지만, 요청이 라우팅 되었음을 이 PreFilter를 통해 알 수 있기 때문에 보다 자세한 디버깅(?)을 위해 추가했습니다.
AuthenticationFilter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@Component
@Slf4j
public class AuthenticationFilter extends AbstractGatewayFilterFactory<AuthenticationFilter.Config> {
private final WebClient webClient;
private static final List<String> EXCLUDED = List.of(
"/login",
"/register",
"/getAllItems"
);
public AuthenticationFilter(WebClient.Builder webClient) {
super(Config.class);
this.webClient = webClient.baseUrl("http://USER-SERVICE").build();
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) ->
isExcluded(exchange.getRequest().getURI().getPath()) ?
chain.filter(exchange) : validToken(exchange, chain);
}
// 특정 url은 인증 제외
private boolean isExcluded(String requestPath) {
return EXCLUDED.stream().anyMatch(requestPath::startsWith);
}
// JWT 검증을 위해 User Server와 통신
private Mono<Void> validToken(ServerWebExchange exchange, GatewayFilterChain chain) {
String header = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
log.info("Header: {}", header);
if (header == null || !header.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
return webClient.get()
.uri("/validation")
.header(HttpHeaders.AUTHORIZATION, header)
.retrieve()
.bodyToMono(ValidTokenResponse.class)
.flatMap(response -> {
if(response.isValid()) {
log.info("Valid Response");
return chain.filter(exchange);
} else {
log.info("Invalid Response");
return setUnauthorizedResponse(exchange);
}
})
.onErrorResume(e -> setUnauthorizedResponse(exchange));
}
// 401 Unauthorized 응답 설정
private Mono<Void> setUnauthorizedResponse(ServerWebExchange exchange) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
public static class Config {
}
}
EXCLUDED 변수에 인증이 필요하지 않은 path를 설정합니다.
@RequiredArgsConstructor 를 사용하여 편하게 생성자를 만들 수도 있었지만,
baseURL 을 지정해주기 위해 이런 구조를 사용했습니다.
apply() 함수가 이 필터가 실행될 때 실질적으로 실행되는 함수라고 생각하면 됩니다.
필터 작동 방식
먼저 EXCLUDED에 포함된 인증이 필요없는 url인지 확인한 후에
Authorization Header로 전달된 값이 Bearer 토큰이 맞는지 확인하고
해당 JWT 토큰이 유효한지 확인하기 위해 User 서버로 요청을 보냅니다.
만약 유효한 토큰이라면 정상적으로 사용자의 정보가 담긴
ValidTokenResponse를 반환할 것이고,
그렇지 않은 경우에는 401 에러코드를 반환합니다. (아직 이 부분은 커스텀 에러가 적용되지 않았습니다)
Config 생성자에는 추가 구성을 위한 변수를 선언할 수 있다고 합니다.
추후에 활용을 할 수도 있겠지만 일단 비워뒀습니다!
application.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
spring:
cloud:
gateway:
globalcors:
corsConfigurations:
'[/**]':
allowedOrigins: "*"
allowedMethods: "*"
allowedHeaders: "*"
discovery:
locator:
enabled: true # Eureka에서 자동으로 서비스 찾기
lower-case-service-id: true # 대소문자 구분 없이 서버 아이디 등록 가능
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Header=Service-Type, user
filters:
- name: AuthenticationFilter
- StripPrefix=0
.
.
.
path가 아니라 Header로 라우팅 될 서버를 구분해 줄 것이고,
요청된 path 그대로 라우팅 할 서버에 전달합니다!
실행 화면
인증이 필요 없는 path로 요청을 보낼 때
로그인은 사용자 인증이 필요없는 기능입니다.
해당 url로 요청을 보내 라우팅이 잘 동작하는지 살펴보겠습니다.
해당 API를 요청하여 User 서버로 라우팅이 제대로 되게 하려면 Header에 Service-Type 변수를 넣고 user 값을 넣어줍니다!
User Server의 /login 으로 DTO에 맞게 요청을 보내면 위와 같이 JWT Token이 정상적으로 전달되는 모습을 확인할 수 있습니다
로그에서도 정상적으로 라우팅이 되어 GlobalFilter로 지정해주었던 CustomFilter를 지난 것을 확인할 수 있습니다
인증이 필요한 path로 요청을 보낼 때
모든 사용자의 정보를 반환하는 url은 사용자 인증이 필요합니다.
해당 url로 요청을 보내보겠습니다
Header에 토큰을 전달하지 않았을 때
인증이 필요하지만 토큰이 전달되지 않았을 때, 구현한대로 401 을 반환합니다.
라우팅이 제대로 되었지만, Header가 제대로 전달되지 않은 것을 로그에서도 확인할 수 있습니다.
올바른 토큰을 전달하지 않았을 때
인증이 필요하지만 토큰이 올바르지 않을 때, 구현한대로 401 을 반환합니다.
정확히 어떤 이유 때문에 에러 코드가 반환되었는지 구별하기 위해 추후에 커스텀 에러필터를 적용하여 구현해야 할 것 같습니다.
라우팅이 제대로 되었지만, 전달된 Header가 제대로 된 토큰의 형태를 띄지 않고 있음을 로그에서도 확인할 수 있습니다.
정상적인 토큰이 전달되었을 때
Token과 Response 모두 정상적으로 전달된 것을 로그에서도 확인할 수 있습니다.
마치며
GraphQL만 포기하면..이렇게 간단한..
레퍼런스가 적은 기술은 독학하기엔 너무 어려운 것 같습니다,,
GPT야 어서 더 똑똑해지렴,,,
다음은 필터에서 전달받은 ValidTokenResponse 를 활용해 사용자의 역할에 따라 API에 대한 접근 권한을 나눠보도록 하겠습니다!
이름값 하는 블로그,,🚧🦺🚜💭,,
참고 자료

