스프링 시큐리티에서도 OAuth2.0을 지원하지만, 이는 SSR에 더 적합해보이고 현재 프로젝트에서 Restful하게 서버를 개발하고 있기에 WebClient
를 활용해 Google api를 사용하기로 했다. 모든 플로우를 직접 구현하기에 OAuth 프로토콜을 공부하기도 좋았다. 나름 공식 문서들을 읽어가며 구현했지만, 처음 사용하는 것이라 메모리 버퍼 크기나 타임아웃 설정 등은 건들이지 못했다. 꼼꼼히 공부하고 다음에 이어서 하는 걸로!
인증 플로우는 아래와 같다.
반환된 유저 정보로 로그인 및 회원가입을 진행한다.
인가는 자체 JWT token을 발급해 진행하였다. 구글에서 발급해주는 access token은 단순 유저 리소스 접근용이기 때문이다.
여러 번 사용되므로 빈으로 등록했다. 의존성을 설정하고 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 빈을 하나 더 등록했다.
login에 대한 endpoint는 https://accounts.google.com/o/oauth2/v2/auth 이다. 여기에 쿼리스트링으로 redirect uri
, scope
, client id
를 설정하면 된다.
우리 프로젝트에서는 FE에서 Login url을 관리한다.
Google Login url로 유저가 권한을 승인하면 사전에 등록한 redirect url
의 쿼리 스트링으로 code
를 받을 수 있다. FE가 redirect url
과 code
를 넘겨주면, 해당 정보로 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());
// ..생략
}
서비스 로직을 보면code
로 token
을 발급 받고 token
으로 유저에 대한 정보를 받는다. 구글에서 사용자의 고유 식별자로 사용하는 ID를 sub
라는 key
의 값으로 넘겨주는데, 이 값을 활용해서 유저를 식별한다. email의 경우 한 계정에서 여러 email을 사용할 수 있다고 한다.
유저가 존재하지 않는다면 저장하고 login 응답을 반환한다.
이 때 사용하는 DTO들이 담고 있는 정보는 아래와 같다. GoogleTokenResponse
와 GoogleUserResponse
는 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
}
enum
으로 관리했다.request body
에 담을 값을 Map 혹은 객체(DTO 등)로 준비한다.request body
를 포함해 POST 요청한다.retrieve()
메서드로 응답 값을 어떻게 extract할지 결정한다. 나는 GoogleTokenResponse
라는 DTO에 담았다.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 구현은 어렵지 않았으나 스프링 시큐리티에서 제공하는 기능들을 찾아보고, 사용하지 않기로 결정하고(^^), RestTemplate
과 WebClient
공부하는 과정이 좀 걸렸다. 프로젝트를 하다 보면 AWS sdk나 외부 api를 사용할 일이 종종 있는데, 서버 간 API 통신 공부를 더 해봐야겠다.