Spring Boot3 OAuth2 구현

·2023년 8월 8일

프로젝트-요즘카페

목록 보기
1/12
post-thumbnail

사전 필요 지식

목표

  • Spring Boot만으로 OAuth2 로그인 기능을 구현한다 (RestTemplate을 곁들인)

동작 과정

  1. 인증 코드를 받는다
  2. 받은 인증 코드를 통해서 Provider(Google, Kakao)에게 토큰을 받는다
  3. 토큰으로부터 Open ID를 파싱한다
    • 파싱해서 얻은 Open ID를 어떻게 사용할 것인지는 여러분의 자유ㅎㅎ

객체 구조 설계

우선 실제로 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에서 진행합니다.

1. DTO 만들기

public record MemberInfo(String openId, String name, String profileImage) {
}

Record 클래스로 위와 같이 간단하게 DTO를 만듭니다.

2. 컨트롤러 만들기

@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는 전혀 수정할 필요가 없게 됐습니다!

3. OAuthClient 추상 클래스 만들기

이제 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();
}
  • HTTP 요청은 스프링에서 제공하는 RestTemplate을 사용합니다.
    • 재활용하기 위해서 필드로 가지도록 했습니다.
  • RestTemplate#postForObject 메서드로 POST 요청을 보내고 프로바이더의 토큰을 받아와야 합니다.
    • 요청 URI, Request Parameter, 응답을 받을 ClassType
    • 위 세 가지의 파라미터가 필요합니다.
  • 필요한 Request Parameter는 다음의 정보들이 필요합니다.
    • code, grant_type, client_id, client_secret, redirect_uri
    • 실질적인 값은 프로바이더마다 다르므로 추상 클래스로 만들었습니다.
  • 요청 URI와 응답을 받을 ClassType(GoogleOAuthToken인지 KakaoOAuthToken인지) 또한 추상 클래스로 만들었습니다.
  • 최종적으로 필요한 파라미터들로 프로바이더에게 토큰을 요청한 뒤 다형성으로 OAuthToken#toMemberInfo를 호출해서 리턴합니다.

4. OAuthClient 각 구현체 만들기

예시로 구글 구현체를 만들어봅니다.

@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에 올리지 않고 잘 관리하셔야 합니다.

5. OAuthToken 만들기

이제 마지막으로 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];
    }
}
  • 필요한 정보(Open ID, Name, Profile Image)들을 Payload들에서 꺼내기 위해 각 Payload 별 Key값이 필요합니다. 프로바이더마다 다른 경우를 생각해서 위와 같이 구현체에서 주입 받도록 protected 생성자를 만들었습니다.
  • 토큰은 Base64로 인코딩 돼있기 때문에 디코딩한 이후 필요한 값들을 파싱하는 로직을 OAuthToken에 구현했습니다.
  • 추상화가 필요하지 않다면 그냥 OAuthToken을 바로 사용해도 됩니다.
    • 이때 OAuthClient에서 RestTemplate#postForObject에서 쓰이는 응답 타입이 추상화돼있는데, 이 또한 그냥 OAuthToken.class로 고정시켜두면 됩니다.

6. OAuthToken 구현체 만들기

예시로 구글 토큰을 구현합니다.

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” : "토큰.토큰.토큰"의 형태로 오게 됩니다.
    따라서 위와 같이 @JsonProperty로 해결을 해줬습니다.
  • 구글 토큰의 Payload 키 값을 상수로 선언해두고 OAuthToken의 생성자를 호출해서 값을 전달해줍니다.

끝!!

Spring Security 의존 없이 OAuth2 로그인을 구현해봤습니다.
위의 예시에서 필요한 부분은 원하는 대로 수정해서 구현하면 됩니다.

요즘 카페의 경우는 위와 같은 OAuth 로그인 플로우를 채택했습니다.
최종적으로 얻은 Open ID를 사용해서 우리 서비스의 JWT를 만들어서 클라이언트에게 리턴합니다.

자세한 코드는 요즘 카페 깃허브 에서 볼 수 있습니다.

profile
渽晛

4개의 댓글

comment-user-thumbnail
2023년 8월 8일

글 잘 봤습니다.

1개의 답글
comment-user-thumbnail
2023년 8월 9일

HTTP 요청을 하기 위해서 WebClient를 사용하는 것이 나아보이는데 RestTemplate의 장점이 있을까요?

1개의 답글