[Springboot] Google OAuth2 API와 WebClient로 인증 구현하기

종미(미아)·2024년 2월 2일
0

🌱 Spring

목록 보기
2/9

들어가며

스프링 시큐리티에서도 OAuth2.0을 지원하지만, 이는 SSR에 더 적합해보이고 현재 프로젝트에서 Restful하게 서버를 개발하고 있기에 WebClient를 활용해 Google api를 사용하기로 했다. 모든 플로우를 직접 구현하기에 OAuth 프로토콜을 공부하기도 좋았다. 나름 공식 문서들을 읽어가며 구현했지만, 처음 사용하는 것이라 메모리 버퍼 크기나 타임아웃 설정 등은 건들이지 못했다. 꼼꼼히 공부하고 다음에 이어서 하는 걸로!

플로우

인증 플로우는 아래와 같다.

반환된 유저 정보로 로그인 및 회원가입을 진행한다.
인가는 자체 JWT token을 발급해 진행하였다. 구글에서 발급해주는 access token은 단순 유저 리소스 접근용이기 때문이다.

인증 구현하기

WebClient 빈 등록하기

여러 번 사용되므로 빈으로 등록했다. 의존성을 설정하고 Configuration 클래스를 생성하여 빈으로 등록한다.

dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	if (isAppleSilicon()) {
		runtimeOnly("io.netty:netty-resolver-dns-native-macos:4.1.94.Final:osx-aarch_64")
	}
}

def isAppleSilicon() {
	System.getProperty("os.name") == "Mac OS X" && System.getProperty("os.arch") == "aarch64"
}

Mac OS에서 netty를 사용하기 위해 의존성을 추가로 설정해주었다.

Netty는 비동기 이벤트 기반의 네트워크 애플리케이션 프레임워크로, Java로 개발된 오픈 소스 프로젝트이다. 주로 네트워크 소켓 프로그래밍을 쉽게 할 수 있도록 도와주는 라이브러리로 사용된다. 스프링의 WebClient는 기본적으로 Netty를 사용한다.

@Slf4j
@Configuration
public class AppConfig {
    @Bean
    public WebClient webClient(){
        ExchangeStrategies strategies = ExchangeStrategies.builder()
                .codecs(configurer -> configurer.defaultCodecs()) // in-memory buffer의 기본 크기 256KB
                .build();
        strategies.messageWriters().stream()
                .filter(LoggingCodecSupport.class::isInstance)
                .forEach(writer -> ((LoggingCodecSupport) writer).setEnableLoggingRequestDetails(true));
        return WebClient.builder()
                .baseUrl("https://oauth2.googleapis.com")
                .exchangeStrategies(strategies)
                .build();
    }
}

base url과 logging만 설정해두었다. logging level을 DEBUG로 설정해야 로그가 찍힌다.

# application.yml
logging:
  level:
    org.springframework.web.reactive.function.client.ExchangeFunctions: DEBUG

Google 유저 정보를 받는 endpoint는 https://www.googleapis.com/oauth2/v1/userinfo 이다. 나는 이 https://www.googleapis.com/oauth2 로 WebClient 빈을 하나 더 등록했다.

Google Login url 생성하기

login에 대한 endpoint는 https://accounts.google.com/o/oauth2/v2/auth 이다. 여기에 쿼리스트링으로 redirect uri, scope, client id를 설정하면 된다.

우리 프로젝트에서는 FE에서 Login url을 관리한다.

Redirect API 서비스 로직 구현하기

Google Login url로 유저가 권한을 승인하면 사전에 등록한 redirect url의 쿼리 스트링으로 code를 받을 수 있다. FE가 redirect urlcode를 넘겨주면, 해당 정보로 Google에 access token을 요청하고, 이 토큰으로 유저 정보를 받는다.

// MemberController.java
    @GetMapping("/api/login/google")
    @Operation(description = "access token과 refresh token을 발급 받는다. 회원가입 되지 않은 유저라면 가입한다.")
    public ResponseEntity<LoginResponse> getGoogleToken(
            @RequestParam final String code,
            @RequestParam(value = "redirect-uri") final String redirectUri) {
        return ResponseEntity.ok(memberService.login(code, redirectUri));
    }

POST api로 만들었어야 했는데, 인증 플로우를 도중에 변경하면서 HTTP 스펙에 맞지 않게 돼버렸다..

// MemberService.java
    @Transactional
    public LoginResponse login(String code, String redirectUri) {
        GoogleTokenResponse googleToken = googleOAuth.requestTokens(code, redirectUri);
        GoogleUserResponse googleUser = googleOAuth.requestUserInfo(googleToken);

        Member member = memberRepository.findBySocialId(googleUser.id()).orElse(null);
        if(Objects.isNull(member)) {
            member = memberRepository.save(googleUser.toEntity());
        }

        String accessToken = jwtTokenProvider.createAccessToken(member.getSocialId());
        String refreshToken = jwtTokenProvider.createRefreshToken(member.getSocialId());
        // ..생략

    }

서비스 로직을 보면codetoken을 발급 받고 token으로 유저에 대한 정보를 받는다. 구글에서 사용자의 고유 식별자로 사용하는 ID를 sub라는 key의 값으로 넘겨주는데, 이 값을 활용해서 유저를 식별한다. email의 경우 한 계정에서 여러 email을 사용할 수 있다고 한다.
유저가 존재하지 않는다면 저장하고 login 응답을 반환한다.

이 때 사용하는 DTO들이 담고 있는 정보는 아래와 같다. GoogleTokenResponseGoogleUserResponse는 Google OAuth 2.0 API로부터 받는 값이기 때문에 필드 명을 API docs의 응답 값과 일치시켰다.

public record GoogleTokenResponse(String access_token, Integer expires_in, String scope, String id_token) {

}

public record GoogleUserResponse(String id, String email, String name, String picture) {
    public Member toEntity() {
        return Member.builder()
                .socialId(id)
                .email(email)
                .name(name)
                .profileUrl(picture)
                .build();
    }
}

WebClient로 Google API 호출하기

// 0
public enum GoogleUri {
    TOKEN_REQUEST("token"),
    USER_INFO_REQUEST("/v1/userinfo");

    private final String uri;

    GoogleUri(final String uri) {
        this.uri = uri;
    }

    public String getUri() {
        return uri;
    }
	// 1
    public static Map<String, Object> getTokenRequestParams(
            final String clientId, final String clientSecret, final String redirectUri, final String code) {
        return Map.of(
                "client_id", clientId,
                "client_secret", clientSecret,
                "redirect_uri", redirectUri,
                "code", code,
                "grant_type", "authorization_code"
        );
    }
}

@Component
@RequiredArgsConstructor
public class GoogleOAuth {
	...
    private final WebClient webClient;
    ...
    public GoogleTokenResponse requestTokens(String code) {
        Map<String, Object> params = GoogleUri.getTokenRequestParams(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, code); // 1
        return webClient.post()
                .uri(GoogleUri.TOKEN_REQUEST.getUri()) // 2
                .accept(MediaType.APPLICATION_JSON)
                .bodyValue(params) // 3
                .retrieve() // 4
                .onStatus(status -> status.is4xxClientError(), response -> {
                    throw new AppException(ErrorCode.INVALID_TOKEN_REQUEST);
                }) // 5
                .bodyToMono(GoogleTokenResponse.class)
                .block(); // 6
    }
  1. api uri를 준비한다. 나는 enum으로 관리했다.
  2. request body에 담을 값을 Map 혹은 객체(DTO 등)로 준비한다.
  3. base url 다음에 올 url을 설정하고 (여기에서는 "/token"이다.)
  4. request body를 포함해 POST 요청한다.
  5. retrieve()메서드로 응답 값을 어떻게 extract할지 결정한다. 나는 GoogleTokenResponse라는 DTO에 담았다.
  6. 이때, 4XX에 해당하는 Error가 발생할 경우 예외처리한다.
  7. Mono를 blocking하여 GoogleTokenResponse를 얻는다.

처음에 RestTemplate을 사용했다가 deprecated 예정이라는 글과, Spring에서 WebClient를 적극 권장한다는 글을 보고 WebClient로 바꿨기에 6번에서 멈칫했다. 하지만 나는 발급 받은 토큰이 있어야 다음 요청을 진행할 수 있기에 blocking할 수 밖에 없었다. WebClient에 대한 docs를 보아도 아래와 같이 처리하는 것을 권장한다.

Mono<Person> personMono = client.get().uri("/person/{id}", personId)
		.retrieve().bodyToMono(Person.class);

Mono<List<Hobby>> hobbiesMono = client.get().uri("/person/{id}/hobbies", personId)
		.retrieve().bodyToFlux(Hobby.class).collectList();

Map<String, Object> data = Mono.zip(personMono, hobbiesMono, (person, hobbies) -> {
			Map<String, String> map = new LinkedHashMap<>();
			map.put("person", person);
			map.put("hobbies", hobbies);
			return map;
		})
		.block();

쉽게 설명해서, 두 요청이 1초씩 걸린다고 할 때 각 요청에 대해 blocking하면 총 2초가 걸린다. 하지만 저렇게 비동기식으로 구현하면 1초만에 두 요청을 수행할 수 있다.

유저 정보를 받는 API 호출도 비슷하기에 생략하겠다.

소감

OAuth 구현은 어렵지 않았으나 스프링 시큐리티에서 제공하는 기능들을 찾아보고, 사용하지 않기로 결정하고(^^), RestTemplateWebClient 공부하는 과정이 좀 걸렸다. 프로젝트를 하다 보면 AWS sdk나 외부 api를 사용할 일이 종종 있는데, 서버 간 API 통신 공부를 더 해봐야겠다.

profile
BE 개발자 지망생 🪐

0개의 댓글