[시즌2] 개인서버 개발/시즌2 설계(완)

API Gateway with Passport

북극곰은콜라 2024. 3. 9. 23:40
반응형

 


개요

이전에 토스 및 네이버(라인?) 아티클에서 API Gateway를 통해서 MSA 구조에서 API로 접근하는 인증 / 인가를 수행하고 있음을 읽었었다.
이는 각 API 서버에서 필요한 기능을 한 곳에서 수행하여 생산성을 높여 효율화를 한 작업으로 감탄했었다. 현 개인서버의 요구사항으로는 최대한 중복 개발을 줄여 생산성을 극대화하며, 귀찮음을 덜고자하는 것이 부합한다.
위 방식을 응용하여 전체적인 아키텍쳐를 설계하고자 한다.

 


서버 아키텍쳐 설계

현 개인서버의 전체적인 아키텍쳐이다.
- Nginx에서 SSL을 수행하고 reverse proxy를 통해 내부 통신을 연결한다.
- Gateway Server는 Nginx로 부터 모든 요청을 받아서 L7 switching의 역할을 할 생각이다.
   기본적으로 Gateway와 Zookeeper를 연동하여 ServiceDiscovery를 통해 auto routing을 수행할 예정이다.
- OAuth Server는 토큰 발급 및 Passport 정보를 생성하는 주체가 될 예정이다.
- User Server는 기본적인 회원관리를 담당할 예정이다.
기본적으로 모든 Server들은 zookeeper에 자신의 정보를 올려서 discovery할 수 있도록 common library를 구성했었다.

 


User Server

유저는 기본적으로 main 계정이 존재하며, Social Login 등 다른 기타 유저정보가 1:N으로 구성될 수 있는 방식을 취할 예정이다.
이는 URI Path를 통해서 Restful하게 표현할 예정이다.

Package 설계

User Server는 기본적으로 Rest 및 Kafka Produce/consume을 하게 될 예정이다.
따라서 core한 서비스 및 기능을 구현하고
inbound / outbound 처리는 별도의 페키지에서 할 수 있도록 구성했다.

URI Path

@Bean
  public RouterFunction<ServerResponse> userMainRoute(final UserMainHandler userMainHandler) {
    return RouterFunctions.route()
        .GET("/main/{id}", userMainHandler::handleGetUserMainId)
        .GET("/main", userMainHandler::handleGetUserMain)
        .POST("/main", userMainHandler::handlePostUserMain)
        .POST("/main/password", userMainHandler::handlePostUserMainPassword)
        .build();
  }
/main, /main/{id} 는 설계에 큰 무리가 없었다.

기본적으로 Password의 CUD는 user server가 수행하기 때문에, password encode/decoder 또한 user server가 관리한다.
따라서 OAuth Server에서 사용자 id/password를 체크하기 위해서는 password 체크가 필요하다.
하지만, password 매칭에 대한 path 설계가 조금 어색하긴 하다...

 


OAuth Server

기본적으로 Spring Authorization Server 라이브러리로 구성했다.
필요한 부분만 구현했으며, 나머지는 기본 설정에 따라서 동작한다.

Authorization

@Bean
public InMemoryOAuth2AuthorizationService authorizationService() {
  return new InMemoryOAuth2AuthorizationService();
}

@Bean
public AuthorizationServerSettings authorizationServerSettings() {
  return AuthorizationServerSettings.builder().build();
}
일단 scale-out에 대한 예정이 없어서 inMemory 방식 기본 모듈을 사용했다.
@Component
@RequiredArgsConstructor
@Slf4j
public class PbearAuthenticationProvider implements AuthenticationProvider {
  ...

  @Override
  public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
    ...

    if (!this.userService.checkPassword(
        String.valueOf(targetAuth.getPrincipal()),
        String.valueOf(targetAuth.getCredentials()))) {
      throw new BadCredentialsException("not match password, id: " + authentication.getPrincipal().toString());
    }

    return new UsernamePasswordAuthenticationToken(
        this.createAuthenticatedPrincipal(String.valueOf(targetAuth.getPrincipal())),
        null,
        targetAuth.getAuthorities()
    );
  }

  ...

  private String createAuthenticatedPrincipal(final String mainId) {
    UserInfo userInfo = this.userService.getUserInfo(mainId);
    try {
      return this.objectMapper.writeValueAsString(Map.of(
          "id", userInfo.id(),
          "mainId", userInfo.mainId()));
    } catch (JsonProcessingException e) {
      return mainId;
    }
  }
}
Authentication은 Custom하게 구현했다.
1. user server에 /main/password API를 호출하여 password 체크를 수행한다.
2. user server로 부터 user정보를 GET하여 principal을 jsonString으로 생성한다.

Client 관리

@Bean
  public InMemoryRegisteredClientRepository registeredClientRepository() {
    RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("common-client")
        ...
        .build();

    return new InMemoryRegisteredClientRepository(registeredClient);
  }
Client도 일단 아직 관리할 필요성을 느끼지 못해서 하드코딩된 inMemory 방식 모듈을 사용했다.

Token 관련

@Bean
public JWKSource<SecurityContext> jwkSource(final KeyProvider keyProvider) {
  KeyPair keyPair = keyProvider.getKeyPair();
  RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
  RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
  RSAKey rsaKey = new RSAKey.Builder(publicKey)
    .privateKey(privateKey)
    .keyID(DEFAULT_OAUTH_KEY_ID)
    .build();
  JWKSet jwkSet = new JWKSet(rsaKey);
  return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> oAuth2TokenCustomizer() {
  return new TokenCustomizer();
}
기본적으로 classpath에서 private key를 load하여 jwk를 구성하고 이를 전체 jwt에 적용하도록 구성했다.
privage key는 기본적으로 github에 공유할 수 없기 때문에, CI 과정에서 특정 디렉토리에서 cp하도록 했다. 

토큰 customizer관련 코드: https://github.com/p-bear/pbear-root/blob/master/pbear-spring/pbear-app-oauth/src/main/java/com/pbear/oauth/core/TokenCustomizer.java

 


Login Page 관련

@Bean
public SecurityFilterChain defaultSecurityFilterChain(final HttpSecurity http,
                                                      final RedirectService redirectService,
                                                      final PBearLoginSuccessHandler pBearLoginSuccessHandler) throws Exception {
  http
    .authorizeHttpRequests(authorize ->
        authorize
            .requestMatchers("/**.css").permitAll()
            .requestMatchers(request -> request.getRequestURI().endsWith("login-page.html")).permitAll()
            .requestMatchers(request -> request.getRequestURI().endsWith("login")).permitAll()
            .requestMatchers(request -> request.getRequestURI().endsWith("error")).permitAll()
            .anyRequest().authenticated()
    )
    .formLogin(formLogin -> formLogin
        .loginPage(redirectService.createLoginPageRedirectUrl())
        .loginProcessingUrl("/login")
        .successHandler(pBearLoginSuccessHandler)
    )
    ...

  return http.build();
}
기본적으로 static resource에서 html을 제공하도록 구성했다.

하지만, 문제는 nginx -> gateway -> oauth로 오는 구성에서 발생헀다.
일단 운영상황에서는 request path에 /gateway/{serverName}이 prefix로 붙어야 각 서비스로 routing 되지만, 로컬 개발시에는 direct로 붙게된다.
그리고 nginx를 통과한 request는 기본적으로 request host가 변경 되어 servletRequest에서 원 host를 찾을 수 없었다. (내부IP로 읽어짐)

reuqest header의 "origin"을 읽을 수 도 있지만, 이는 모든 상황에서 통용될 수 없다 판단했다.

이를 해결하기 위해, 운영서버를 판단하는 기준인 spring profile active로 redirect URL를 다시 작성하는 방법을 취했다. 기본적으로 url을 customize할 수 있는 옵션들은, 코드를 보았을 때 full url이면 해당 url로 대체되도록 구현되어있어, 이를 활용했다.

 


Gateway Server

Zookeeper 연동

build.gradle

...

dependencies {
    ...
    implementation 'org.springframework.cloud:spring-cloud-starter-zookeeper-discovery'
}

application.yml

spring:
  ...
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
위 옵션으로 간단하게 zookeeper와 연동할 수 있다.
이는 zookeeper-discovery 라이브러에서 DiscoveryClient를 구현해 제공해주기 때문이다.
기본적으로 discovery에 등록된 serviceName으로 routing을 해준다.

Passport Global Filter

@Component
@RequiredArgsConstructor
public class PassportGlobalFilter implements GlobalFilter {
  private static final String PASSPORT_HEADER_PREFIX = "X-PP-";
  private final PassportService passportService;

  @Override
  public Mono<Void> filter(final ServerWebExchange exchange, final GatewayFilterChain chain) {
    return Mono
        .zip(this.handleExchangeWithPassport(exchange), Mono.just(chain))
        .flatMap(tuple -> tuple.getT2().filter(tuple.getT1()));
  }

  private Mono<ServerWebExchange> handleExchangeWithPassport(final ServerWebExchange serverWebExchange) {
    HttpHeaders httpHeaders = serverWebExchange.getRequest().getHeaders();
    if (!httpHeaders.containsKey(HttpHeaders.AUTHORIZATION)) {
      return Mono.just(serverWebExchange);
    }
    return this.passportService.getPassportData(httpHeaders.getFirst(HttpHeaders.AUTHORIZATION))
        .map(passportData -> {
          ServerHttpRequest.Builder builder = serverWebExchange.getRequest().mutate();
          passportData.forEach((headerName, headerValue) ->
              builder.headers(header -> header.add(PASSPORT_HEADER_PREFIX + headerName, headerValue)));
          return serverWebExchange.mutate()
              .request(builder.build())
              .build();
        });
  }
}
1. 기본적으로 모든 request에서 Authorization이 header에 있는지 체크한다.
2. 있다면 해당 header value를 넣어서 oauth server의 resource API인 /passport로 요청한다.
3. 응답 결과를 받아서 passport data를 request header에 X-PP-를 prefix해서 추가한다.
// 현 구현으로는 X-PP-id, X-PP-mainId 2가지가 있다.

추후 인가가 필요한 서비스는 X-PP- 헤더를 읽어서 해결할 수 있을 것이다.

 


Conclusion

여기까지가 기본적인 서비스 개발을 위한 준비가 완료되었다고 판단된다.
이후 개발하는 서비스들은 여기까지 구현된 사항으로 보다 쾌척하게 작업할 수 있을 것으로 기대된다.
생산성의 향상으로 신규 개발의 저항감을 어느정도 해소 할 수 있기를 빌어본다...

현 계획된 서비스는 2가지가 있다.
1. 개인 자산 통합관리
2. 지하철 데이터를 활용한 hot place 시각화 서비스
올해안으로 완성할 수 있기를 기도해본다..

 


REFERENCE

Authorization Server

- https://datatracker.ietf.org/doc/html/rfc6749

- https://docs.spring.io/spring-authorization-server/reference/protocol-endpoints.html

- https://github.com/spring-projects/spring-authorization-server/tree/main/samples/demo-authorizationserver/src/main/java/sample

API Gateway Server

- https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/global-filters.html

- https://github.com/spring-cloud-samples/spring-cloud-gateway-sample/blob/main/src/main/java/com/example/demogateway/DemogatewayApplication.java

- https://toss.tech/article/slash23-server

 

 

반응형