앞선 글에서 프론트엔드와 백엔드가 분리되었을 때 OAuth 인증을 어떻게 구현할 수 있는지 알아보았습니다.
이번 글에서는 확장을 고려하며 서버를 구현해 볼 생각입니다.
현재 우리 서비스는 Google에 대한 OAuth만을 지원합니다. 하지만 이 부분은 Naver, Kakao 등 요구사항이 변경됨에 따라 확장이 가능합니다.
어떻게 구조를 설계해야 요구사항 변경에 유연하게 대응할 수 있을까요?
기존 API
GET /oauth/google/authorized-uri
POST /oauth/google/login
변경된 API
GET /oauth/{provider}/authorized-uri
POST /oauth/{provider}/login
간단하죠? 사용자는 naver가 추가되면 /oauth/naver/...
에 요청을 보내면 됩니다. 이제 이 구조에 맞춰서 어떻게 서버를 만들어보겠습니다.
어차피 provider 이름을 가지고 구현체를 찾는 로직은 authorized-url를 가져오는 로직이나, 로그인 로직이나 동일합니다.
굳이 중복해서 확인할 필요 없으니 Authorized Uri를 가져오는 로직만 살펴보겠습니다.
컨트롤러
@GetMapping("/oauth/{provider}/authorized-uri")
public ResponseEntity<GetAuthorizedUriResponse> getAuthorizedUri(@PathVariable String provider) {
return ResponseEntity.ok(authService.getAuthorizedUri(provider));
}
서비스
public class AuthService {
private final OAuthProviderResolver oAuthProviderResolver;
private final AuthMemberRepository authMemberRepository;
private final RandomStringFactory randomStringFactory;
private final JwtTokenProvider jwtTokenProvider;
/**
* 입력받은 providerName을 사용해 해당되는 OAuthProvider를 찾는다.
* 반환된 OAuthProvider은 Authorized URL을 만들어 반환한다.
*/
public GetAuthorizedUriResponse getAuthorizedUri(String providerName) {
OAuthProvider oAuthProvider = oAuthProviderResolver.find(providerName); // 이 부분
String state = randomStringFactory.create();
return new GetAuthorizedUriResponse(oAuthProvider.getAuthorizedUriWithParams(state));
}
}
서비스 계층의 getAuthorizedUri
를 살펴보면 providerName을 가지고 OAuthProvider를 가져옵니다.
추후 설명드리겠지만, OAuthProvider는 외부 OAuth 제공자에게 응답을 받아오는 책임을 가진 객체입니다.
지금은 OAuthProviderResolver에 대해 알아보겠습니다.
객체지향 원리에 따르면 하나의 객체는 하나의 역할이 있습니다. 이 객체의 역할은 무엇일까요?
바로 providerName을 받아 적절한 OAuthProvider를 찾아오는 역할을 갖습니다.
코드를 보면 금방 이해하실 거라 생각합니다.
@Component
@RequiredArgsConstructor
public class OAuthProviderResolver {
private final List<OAuthProvider> oAuthProviders;
public OAuthProvider find(String providerName) {
return oAuthProviders.stream()
.filter(provider -> provider.match(providerName))
.findAny()
.orElseThrow(() -> new ApiException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND));
}
}
OAuthProviderResolver는 스프링에 등록되어 있는 모든 OAuthProvider 빈들에 의존성을 갖습니다.
이들을 List로 관리하면서 providerName이 들어오면, 이름이 일치하는 OAuthProvider를 찾아 반환합니다.
서비스는 OAuthProvider를 반환받아, 해당 객체를 사용해서 authorized-uri도 받아오고 로그인 요청도 수행하는데요.
그렇다면 OAuthProvider는 어떻게 구현되어 있을까요?
OAuthProvider는 인터페이스입니다. Google, Naver, Kakao 등 다양한 인증을 처리하기 위해 해당 객체를 implements하여 구현체를 만듭니다.
public interface OAuthProvider {
OAuthType getOAuthType();
OAuthProperties getOAuthProperties();
OAuthClient getOAuthClient();
/**
* 해당 Provider가 처리할 수 있는지 확인한다.
*/
default boolean match(String name) {
OAuthType type = getOAuthType();
return Objects.equals(type.getText(), name);
}
/**
* 입력한 값들을 조합해 클라이언트에게 반환할 AuthorizedUri을 만든다.
*/
default String getAuthorizedUriWithParams(String state) {
OAuthProperties properties = getOAuthProperties();
return properties.getAuthorizedUriEndpoint() + "?"
+ "client_id=" + properties.getClientId()
+ "&redirect_uri=" + properties.getRedirectUri()
+ "&scope=" + String.join(",", properties.getScope())
+ "&response_type=" + properties.getResponseType()
+ "&state=" + state;
}
/**
* authCode를 사용해 Access Token을 받아온 뒤, Resource Server에서 여러 정보를 가져온다.
*/
default OAuthUserResponse getOAuthUserResponse(String authCode) {
OAuthClient client = getOAuthClient();
AccessTokenResponse accessTokenResponse = client.getAccessToken(authCode);
return client.getOAuthUserResponse(accessTokenResponse.getAccessToken());
}
}
우선 각 OAuthProvider는 다음과 같은 구조를 갖습니다.
이 구조에 따라 OAuthProvider의 구현체를 생성해내면 match, getAuthorizedUriWithParams, getOAuthUserResponse 세 개의 템플릿 메서드를 사용할 수 있습니다.
입력받은 name을 처리할 수 있는 객체인지 확인합니다.
ex. google -> GoogleOAuthProvider, naver -> NaverOAuthProvider
모든 authorized-uri는 동일한 양식을 갖습니다. 각 인증 방식마다 달라지는 값들에 대해서는 properties에 값을 입력하고, 나머지 부분은 저눕 동일하게 가져갑니다.
해당 메서드를 통해 다음과 같은 authorized-uri를 만들 수 있습니다.
http://localhost:8443/login/oauth2/code/google?state=auKjwwAgBdfA0Q4XrKjFkqEjDY1NbPPdlXs4GVLG_iA&code=4%2F0AfJohXm31FsnnZRjzzjlBxjEG5wQar6qY5vOj13x7rDQzc5OF60UHt9wky_Sudubml0Qag&scope=email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid&authuser=0&prompt=consent
우리 서비스에서는 login 요청은 다음 단계로 이루어집니다.
1. auth code를 입력받아 Access Token을 가져옵니다.
2. 가져온 Access Token을 사용해 사용자의 이메일, 프로필 이미지 등을 가져옵니다.
이처럼 모든 OAuth 제공자에 대해 동일한 흐름을 가져가기 때문에 템플릿 메서드로 만들어 주었습니다.
실제 구현체를 살펴보겠습니다.
@Configuration
public class OAuthConfig {
@Bean
public OAuthProvider googleOAuthProvider(OAuthProperties googleProperties, OAuthClient googleOAuthClient) {
return new GoogleOAuthProvider(googleProperties, googleOAuthClient);
}
@Bean
public OAuthProperties googleProperties() {
return new GoogleProperties();
}
@Bean
public OAuthClient googleOAuthClient(OAuthProperties properties) {
return new GoogleOAuthClient(properties);
}
}
@RequiredArgsConstructor
public class GoogleOAuthProvider implements OAuthProvider {
private static final OAuthType TYPE = OAuthType.GOOGLE;
private final OAuthProperties googleProperties;
private final OAuthClient client;
@Override
public OAuthType getOAuthType() {
return TYPE;
}
@Override
public OAuthProperties getOAuthProperties() {
return googleProperties;
}
@Override
public OAuthClient getOAuthClient() {
return client;
}
}
@ConfigurationProperties(prefix = "oauth.google")
@Getter
@Setter
public class GoogleProperties implements OAuthProperties {
private String authorizedUriEndpoint;
private String clientId;
private String clientSecret;
private String redirectUri;
private String[] scope;
private String responseType;
private String tokenUri;
private String userInfoUri;
}
@RequiredArgsConstructor
public class GoogleOAuthClient implements OAuthClient {
private final OAuthProperties properties;
@Override
public OAuthProperties getOAuthProperties() {
return properties;
}
@Override
public AccessTokenResponse getAccessToken(String authCode) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", properties.getClientId());
params.add("client_secret", properties.getClientSecret());
params.add("code", authCode);
params.add("redirect_uri", properties.getRedirectUri());
params.add("grant_type", "authorization_code");
HttpEntity<?> request = new HttpEntity<>(params, headers);
ResponseEntity<AccessTokenResponse> response =
new RestTemplate().postForEntity(properties.getTokenUri(), request, AccessTokenResponse.class);
if (response.getStatusCode().is2xxSuccessful()) {
return response.getBody();
}
throw new ApiException(ErrorCode.ACCESS_TOKEN_FETCH_FAIL);
}
@Override
public OAuthUserResponse getOAuthUserResponse(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
HttpEntity<Object> entity = new HttpEntity<>(headers);
ResponseEntity<OAuthUserResponse> response = new RestTemplate().exchange(
properties.getUserInfoUri(), HttpMethod.GET, entity, OAuthUserResponse.class);
if (response.getStatusCode().is2xxSuccessful()) {
return response.getBody();
}
throw new ApiException(ErrorCode.USER_INFO_FETCH_FAIL);
}
}
정리하면 아래 이미지와 같은 구조를 갖게 되었습니다.
구조에는 정답이 없습니다. 오답은 있다고 합니다
저 역시 제가 아는 지식을 최대한 활용해가며 구조를 작성했지만, 시간이 지나고 왜 이렇게 짰을까 의문이 들 수 있습니다.
그래도 이 구조를 만들면서 많은 고민을 했기 때문에 해당 방식을 공유하고 싶었습니다.
이 글을 통해 다른 분들에게 인사이트를 제공할 수 있다면 참 좋겠네요.
혹시 제가 실수한 부분이 있거나, 혹은 더 좋은 방식이 있다면 피드백 부탁드리겠습니다.
감사합니다. 전체 코드는 여기에서 확인하실 수 있습니다.