
우선 실제로 Provider와 통신을 할 클라이언트가 필요합니다.
이때 인가코드를 받아서 프로바이더에게 POST 요청을 하고, 받은 토큰에서 Open ID를 파싱하는 과정은 동일합니다.
따라서 동일한 행위를 OAuthClient에 구현해두고, 프로바이더별로 필요한 파라미터는 각 구현체에서 받아서 쓰도록 했습니다.

프로바이더에게 POST 요청으로 받은 OAuthToken에서 MemberInfo를 생성해서 반환합니다.
만약 받은 토큰 내부에서 Payload(Key, Value)를 꺼낼 때 Key 값이 프로바이더마다 다르다면 위와 같이 구현하면 됩니다. (구글, 카카오의 경우는 토큰 내부 Payload들의 Key 값이 동일하므로 추상화가 필요없습니다. 하지만 예시에서는 추상화한 상태로 진행할 예정입니다.)
OAuthClient와 마찬가지로 토큰에서 MemberInfo를 만드는 것은 동일하므로 OAuthToken으로 추상화합니다.

최종적으로 위와 같은 그림입니다.
1. OAuthClient에게 인가 코드와 함께 MemberInfo를 요청한다
2. OAuthClient가 프로바이더에게 HTTP 요청을 해서 OAuthToken을 받는다
3. OAuthToken에게 MemberInfo를 요청한다
아래와 같이 간단하게 인가 코드를 받아서 MemberInfo를 리턴해주는 API를 만들어봅시다.
POST /login/{provider}
Request Parameter: String code
Response Parameter: String openId, String name, String profileImage
예시는 JDK17, Spring Boot 3.1.1에서 진행합니다.
public record MemberInfo(String openId, String name, String profileImage) {
}
Record 클래스로 위와 같이 간단하게 DTO를 만듭니다.
@Controller
public class AuthController {
@PostMapping("/login/{provider}")
public ResponseEntity<MemberInfo> login(@RequestParam("code") final String code,
@PathVariable("provider") final String provider) {
return null;
}
}
아직 내부 로직이 존재하지 않으므로 위와 같이 간단하게 만들었습니다.
근데 @PathVariable로 받고 있는 String을 OAuthClient로 받고 싶습니다.
@PostMapping("/login/{provider}")
public ResponseEntity<MemberInfo> login(@RequestParam("code") final String code,
@PathVariable("provider") final OAuthClient client) {
final MemberInfo memberInfo = client.getMemberInfo(code);
return ResponseEntity.ok(memberInfo);
}
따라서 위와 같이 Path Variable로 객체를 받도록 구현했습니다.
코드에서 필요하지만 아직 구현되지 않은 객체들은 클래스 생성만 해둡니다.
String을 클래스로 받기 위해서 Converter<S, T>를 구현한 StringToOAuthClientConverter를 구현합니다.
public class StringToOAuthClientConverter implements Converter<String, OAuthClient> {
@Override
public OAuthClient convert(String source) {
if (source.equalsIgnoreCase("kakao")) {
return new KakaoOAuthClient();
}
if (source.equalsIgnoreCase("google")) {
return new GoogleOAuthClient();
}
throw new IllegalArgumentException();
}
}
컨버터를 등록하기 위해 WebMvcConfigurer를 구현한 WebMvcConfig도 구현합니다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addFormatters(final FormatterRegistry registry) {
registry.addConverter(new StringToOAuthClientConverter());
}
}
이제 String으로 받지 않고 바로 OAuthClient로 받을 수 있게 됐습니다!
하지만 만약 새로운 프로바이더가 추가되는 경우를 생각하면 위의 컨버터를 수정해줘야 합니다. (새 OAuthClient 구현체를 String과 매핑해야 하므로)
또한 String으로 매핑하고 있는데 이는 개발자의 실수가 발생할 수 있는 포인트입니다.
새로운 프로바이더가 추가되더라도 구현체만 추가하면 동작하도록 만들고 싶습니다.
우선 Converter를 아래와 같이 수정합니다.
@Component
public class StringToOAuthClientConverter implements Converter<String, OAuthClient> {
private final List<OAuthClient> clients;
public StringToOAuthClientConverter(List<OAuthClient> clients) {
this.clients = clients;
}
@Override
public OAuthClient convert(String source) {
return clients.stream()
.filter(client -> client.equalsNameIgnoreCase(source))
.findAny()
.orElseThrow(IllegalArgumentException::new);
}
}
달라진 점을 살펴보면,
@Component로 스프링 빈으로 관리합니다.OAuthClient들을 주입 받고 있습니다.OAuthClient#equalsNameIgnoreCase메서드를 호출해서 알맞는 OAuthClient를 리턴하도록 합니다.Name을 가지도록 해서 같은 지 확인하도록 합니다.WebMvcConfig는 어떻게 됐을까요??
이제 WebMvcConfig는 필요 없어졌습니다.
기존에 new 키워드로 생성하던 Converter가 이제 스프링 빈으로 관리되기 때문에 자동으로 컨버터로 등록되어 사용됩니다.
어떻게 가능하냐구요? 힌트: @SpringBootApplication의 @EnableAutoConfiguration
결론은 이제 프로바이더가 추가되어도 Converter는 전혀 수정할 필요가 없게 됐습니다!
이제 HTTP 통신을 하는 OAuthClient를 만들어야 합니다.
public abstract class OAuthClient {
private static final String CODE = "code";
private static final String GRANT_TYPE = "grant_type";
private static final String CLIENT_ID = "client_id";
private static final String CLIENT_SECRET = "client_secret";
private static final String REDIRECT_URI = "redirect_uri";
private final static RestTemplate restTemplate = new RestTemplate();
public MemberInfo getMemberInfo(final String code) {
final MultiValueMap<String, String> parameter = setParameters(code);
OAuthToken oAuthToken = restTemplate.postForObject(URI.create(tokenUri()), parameter, getType());
return Objects.requireNonNull(oAuthToken).toMemberInfo();
}
private MultiValueMap<String, String> setParameters(final String code) {
final MultiValueMap<String, String> parameter = new LinkedMultiValueMap<>();
parameter.add(CODE, code);
parameter.add(GRANT_TYPE, "authorization_code");
parameter.add(CLIENT_ID, clientId());
parameter.add(CLIENT_SECRET, clientSecret());
parameter.add(REDIRECT_URI, redirectUri());
return parameter;
}
public abstract boolean equalsNameIgnoreCase(String name);
protected abstract String clientId();
protected abstract String clientSecret();
protected abstract String redirectUri();
protected abstract String tokenUri();
protected abstract <T extends OAuthToken> Class<T> getType();
}
RestTemplate을 사용합니다.RestTemplate#postForObject 메서드로 POST 요청을 보내고 프로바이더의 토큰을 받아와야 합니다.OAuthToken#toMemberInfo를 호출해서 리턴합니다.예시로 구글 구현체를 만들어봅니다.
@Component
public class GoogleOAuthClient extends OAuthClient {
private final static String name = "GOOGLE";
@Value("${spring.auth.google.tokenUri}")
private String googleTokenUri;
@Value("${spring.auth.google.clientId}")
private String clientId;
@Value("${spring.auth.google.clientSecret}")
private String clientSecret;
@Value("${spring.auth.google.redirectUri}")
private String redirectUri;
@Override
protected Class<? extends OAuthToken> getType() {
return GoogleToken.class;
}
@Override
protected String clientId() {
return clientId;
}
@Override
protected String clientSecret() {
return clientSecret;
}
@Override
protected String redirectUri() {
return redirectUri;
}
@Override
protected String tokenUri() {
return googleTokenUri;
}
@Override
public boolean equalsNameIgnoreCase(String name) {
return GoogleOAuthClient.name.equalsIgnoreCase(name);
}
}
Application Properties 파일에서 필요한 정보들을 관리하는 시나리오를 생각해봤습니다.
민감한 정보이기 때문에 공개 Github Repository에 올리지 않고 잘 관리하셔야 합니다.
이제 마지막으로 OAuthToken#toMemberInfo를 구현해야 합니다.
프로바이더로부터 오는 토큰은 점(.) 두 개로 이루어진 문자열(JWT)입니다.
두 점의 사이에 있는 값에 Payload가 담겨있으므로 파싱해야합니다.
public abstract class OAuthToken {
private static final String JWT_TOKEN_DELIMITER = "\\.";
private static final String PAYLOADS_DELIMITER = ",";
private static final String ENTRY_DELIMITER = "\"";
private static final int PAYLOAD_INDEX = 1;
private static final int VALUE_INDEX = 3;
protected final String idToken;
protected final String subject;
protected final String name;
protected final String image;
protected OAuthToken(String idToken, String subject, String name, String image) {
this.idToken = idToken;
this.subject = subject;
this.name = name;
this.image = image;
}
public MemberInfo toMemberInfo() {
final List<String> payloads = getPayloads();
String openId = parse(payloads, subject);
String name = parse(payloads, this.name);
String image = parse(payloads, this.image);
return new MemberInfo(openId, name, image);
}
private List<String> getPayloads() {
final String payload = idToken.split(JWT_TOKEN_DELIMITER)[PAYLOAD_INDEX];
byte[] decodedBytes = Base64.getUrlDecoder().decode(payload);
final String decoded = new String(decodedBytes);
return Arrays.asList(decoded.split(PAYLOADS_DELIMITER));
}
private String parse(final List<String> payLoads, final String key) {
final String entry = payLoads.stream()
.filter(payload -> payload.contains(key))
.findAny()
.orElseThrow();
return entry.split(ENTRY_DELIMITER)[VALUE_INDEX];
}
}
OAuthToken에 구현했습니다.OAuthToken을 바로 사용해도 됩니다.OAuthClient에서 RestTemplate#postForObject에서 쓰이는 응답 타입이 추상화돼있는데, 이 또한 그냥 OAuthToken.class로 고정시켜두면 됩니다.예시로 구글 토큰을 구현합니다.
public class GoogleToken extends OAuthToken {
private static final String SUBJECT = "sub";
private static final String NAME = "name";
private static final String PICTURE = "picture";
public GoogleToken(@JsonProperty("id_token") String idToken) {
super(idToken, SUBJECT, NAME, PICTURE);
}
}
“id_token” : "토큰.토큰.토큰"의 형태로 오게 됩니다.OAuthToken의 생성자를 호출해서 값을 전달해줍니다.Spring Security 의존 없이 OAuth2 로그인을 구현해봤습니다.
위의 예시에서 필요한 부분은 원하는 대로 수정해서 구현하면 됩니다.

요즘 카페의 경우는 위와 같은 OAuth 로그인 플로우를 채택했습니다.
최종적으로 얻은 Open ID를 사용해서 우리 서비스의 JWT를 만들어서 클라이언트에게 리턴합니다.
자세한 코드는 요즘 카페 깃허브 에서 볼 수 있습니다.
글 잘 봤습니다.