[프로젝트] OAuth 2.0 활용한 소셜 로그인 서비스 구현

조찬영·2023년 11월 9일
0

들어가기 앞서

웹사이트, 애플리케이션에 접속을 하면 해당 서버에서 자체적인 로그인 서비스만을 제공하는 것 보다 카카오, 네이버 , 구글 등의 외부 소셜 로그인을 함께 제공하는 서비스가 증가하고 있는데요.

사용자 관점에서 회원가입이라는 번거로운 절차를 거치지 않아 편리하고 서버로서도 사용자 인증 관련 부분에 대한 부담을 덜 수 있으니 그만큼 이용 빈도수가 증가하는 게 아닐까 생각됩니다.

그래서 저 또한 프로젝트에서 OAuth 2.0를 활용하여 소셜 로그인을 구현해 보려 하는데요. 저는 카카오와 네이버 로그인 서비스를 구현하려 합니다.



1. OAuth 관련 기본 개념 정리

프로젝트에서 OAuth를 어떻게 다뤘고 왜 이러한 방식을 사용했는지에 대한 것에 초점을 맞추려 합니다.

그래서 전체적인 흐름에서 중요하다고 생각되어진 부분들에 대해서만 간략하게 정리 하려 합니다.

1-1. OAuth 란?

사용자(Resource Owner) 가 리소스 서버에서 제공하는 자원에 대한 접근 권한을 다른 애플리케이션에게 부여할 수 있도록 만들어 주는 개방형 표준 프로토콜 입니다.

이로서 외부 소셜 로그인(카카오, 네이버, 구글)을 통한 사용자 인증을 수행할 수 있습니다.
하지만 인가(Authorization)에 대한 부분은 여전히 서버에서 담당해야 합니다.

1-2. OAuth 2.0 구성 요소

  • Resource Owner
    • 자원을 소유하고 있는 주체(주인)입니다.
    • 외부 소셜 로그인을 사용하는 유저가 바로 Resource Owner 입니다.

  • Resource Server
    • 자원을 소유하고 제공하는 서버를 말합니다.
    • 여기서 말하는 자원에는 유저의 개인 정보(자격 증명) 등이 포함합니다.
    • 즉, 소셜 로그인을 제공하는 네이버, 카카오, 구글 등이 Resource Server가 됩니다.
  • Authorization Server

    • 사용자 인증이 성공할 경우 인가 코드(Authorization code)를 발급합니다.
    • 클라이언트는 인가 코드(Authorization code) 를 통해 Authorization ServerAccessToken을 요청할 수 있습니다.

  • Client

    • 우리가 개발하고 있는 웹 서비스, 애플리케이션이 클라이언트입니다.
    • 클라이언트는 AccessToken 을 통해 Resource Server 로 부터 자원을 요청할 수 있습니다.
    • Resource Server 로 부터 자원을 요청하기 때문에 client라는 명칭이 사용됩니다.
  • Scope

    • 전달받은 토큰으로 자원에 접근할 수 있는 범위를 뜻합니다.

1-3. Authorization Code Grant

OAuth 2.0 에서 클라이언트는 4가지 인증 방식으로 토큰을 요청할 수 있습니다.

그 중에서 저는 가장 보편적인 방식인 Authorization Code Grant 으로 토큰을 요청하려 합니다.

Authorization Code Grant 방식

사용자에게 명확하게 권한 부여를 요청하는 페이지를 제공하고 인가 코드로 서 Access token을 api를 통해 발급받을 수 있기 때문에 토큰 탈취 위험으로부터 어느 정도 벗어날 수 있습니다.



2. OAuth 2.0 기본 설정 세팅

이제 OAuth 2.0 을 활용한 소셜 로그인을 구현하기 위한 기본값들을 세팅하겠습니다.

(client id, secret 을 받는 과정은 생략했습니다.)

2.1 gradle 의존성 주입

 // OAuth 2.0
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

다음과 같이 의존성을 주입해 주었습니다.


2.2 yaml 파일 설정

yaml

  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: 
            client-secret: 
            authorization-grant-type: authorization_code
            redirect-uri: 
            client-authentication-method: POST
          naver:
            client-id: 
            client-secret: 
            authorization-grant-type: authorization_code
            redirect-uri: 
            client-name: Naver
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response

Client registration

  • 발급 받은 client-idclient-secret 값을 등록해줍니다.
    (각각 식별자와 비밀 key 역할)

여기서 주의해야 할 점은 절대로 cleint-idclient - secret 이 외부에 노출되어서는 안됩니다.


authorization-grant-type

  • OAuth 2.0 인증 방식중 Authorization Code Grant 방식을 사용한다는 것을 명시합니다.
  • Authorization Code Grant 방식은 인가 코드를 uri를 통해서 발급 받기 때문에 인가 코드를 발급 받을 redirect-uri를 설정합니다.

provider

  • GitHub, Google 등의 해외 기업은 spring boot에 provider 정보가 저장되어 있지만 아직 국내 기업은 provider 정보가 저장되어 있지 않아 provider 정보를 등록해주어야 합니다.

provider 정보를 포함한 소셜로그인에 대한 상세 가이드는 해당 서비스의 Develop 문서에서 찾으실 수 있습니다.



3. OAuth 2.0 소셜 로그인 구현하기

이제 본격적으로 소셜 로그인을 구현해보겠습니다.

현재 저는 rest api 프로젝트를 개발하고 있기에 그에 맞게 코드로 구현하려 합니다.


3.1 OAuth2 Params


OAuth2Provider

public enum OAuth2Provider {
    KAKAO, NAVER
}

OAuth2LoginParams

public interface OAuth2LoginParams {

    OAuth2Provider oAuth2Provider();

    MultiValueMap<String, String> authorizationBody();
}

redirect uri로 발급받은 인가 코드를 Authorization server에 요청하기 위해서 OAuth2LoginParams를 생성해 주었습니다.

  • Oauth2Provider : 해당 파라미터가 어떤 리소스 서버에 대한 요청인지 식별하기 위해서 생성했습니다.
  • authorizationBody : Authorization Server로부터 토큰을 발급받기 위한
    request(인가 코드, grant type, client id) 의 body 값입니다.

(HTTP 요청이나 응답에서는 종종 같은 이름의 파라미터나 헤더를 여러 개 가질 수 있기때문에 Spring Framework 에서 제공하는 MultiValueMap 사용했습니다.)


3.2 OAuth2ApiClient

이제 restTemplate 으로 본격적인 통신을 할 수 있는 api client를 추상화해주고 그에 맞게 각각의 구현체들(카카오, 네이버)을 생성해 주었습니다.

OAuth2ApiClient

public interface OAuth2ApiClient {
    OAuth2Provider oAuthProvider();
    String requestAccessToken(OAuth2LoginParams params) throws Exception;
    OAuth2UserInfo requestUserInfo(String accessToken);
}

KakaoApiClient

@Component
@RequiredArgsConstructor
@Slf4j
public class KakaoApiClient implements OAuth2ApiClient {
		

    /**
     * @param params params.authorizationBody(): has authorization-code for issued access token
     *               params.oAuth2Provider: inform provider
     * @return access token
     * @throws OAuth2RestClientException
     */

    @Override
    public String requestAccessToken(OAuth2LoginParams params) throws OAuth2RestClientException {
        // header
        HttpHeaders httpHeaders = createUrlEncodedHttpHeaders();
        // body
        MultiValueMap<String, String> authorizationBody = params.authorizationBody();
        authorizationBody.add("grant_type", GRANT_TYPE);
        authorizationBody.add("client_id", clientId);
        //request
        HttpEntity<?> request = new HttpEntity<>(authorizationBody, httpHeaders);

        //kakaoTokens
        KakaoTokens kakaoTokens = restTemplate.postForObject(tokenUri, request, KakaoTokens.class);

        if (kakaoTokens == null) {
            throw new OAuth2RestClientException(
                "[KakaoApiClient] Failed to retrieve access token.");
        }
        log.info("[KakaoApiClient] KakaoTokens is successfully issued");
        return kakaoTokens.getAccessToken();
    }

NaverApiClient

@Component
@RequiredArgsConstructor
@Slf4j
public class NaverApiClient implements OAuth2ApiClient {

    /**
     * @param params params.authorizationBody(): has authorization-code for issued access token
     *               params.oAuth2Provider: inform provider
     * @return access token
     * @throws OAuth2RestClientException
     */
    @Override
    public String requestAccessToken(OAuth2LoginParams params) {
        //header
        HttpHeaders httpHeaders = createUrlEncodedHttpHeaders();
        //body
        MultiValueMap<String, String> body = params.authorizationBody();
        body.add("grant_type", GRANT_TYPE);
        body.add("client_id", clientId);
        body.add("client_secret", clientSecret);
        //request
        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);
        //NaverTokens
        NaverTokens naverTokens = restTemplate.postForObject(tokenUri, request, NaverTokens.class);

        if (naverTokens == null) {
            throw new OAuth2RestClientException(
                "[NaverApiClient] Failed to retrieve access token.");
        }

        log.info("[NaverApiClient] naverTokens is successfully issued");
        return naverTokens.getAccessToken();
    }

위의 ApiClient들의 역할은 결국 Authorization Server 에 요청할 request를 생성하는 것입니다.

그리고 최종적으로 토큰을 return 받는 것을 확인할 수 있습니다.


3.3 access token 으로 유저 데이터 요청하기

이제 인가 코드로 access token 을 발급 받고 이를 통해 유저 데이터를 요청하는 전체적인 흐름이 담긴 서비스 레이어를 살펴보겠습니다.

OAuth2Config

@Configuration
public class OAuth2Config {

    @Bean
    public Map<OAuth2Provider, OAuth2ApiClient> oAuth2ApiClientMap(List<OAuth2ApiClient> clients) {
        return clients.stream().collect(
            Collectors.toUnmodifiableMap(OAuth2ApiClient::oAuthProvider, Function.identity())
        );
    }
}

인가 코드로 토큰을 요청하기 이전에 먼저 파라미터가 어떤 리소스 서버에 대한 요청인지 구분하기 위해서 위와 같이 oAuth2ApiClientMap을 스프링 빈으로 등록해 두었습니다.

OAuth2LoginService

@Service
@RequiredArgsConstructor
@Slf4j
public class OAuth2LoginService {

    private final Map<OAuth2Provider, OAuth2ApiClient> oAuth2ApiClientMap;
    
     ...(생략) 
    
   
    public OAuth2UserInfo requestUserInfo(OAuth2LoginParams params) {
        OAuth2ApiClient oAuth2ApiClient = oAuth2ApiClientMap.get(params.oAuth2Provider());
        try {
            String accessToken = oAuth2ApiClient.requestAccessToken(params);
            return oAuth2ApiClient.requestUserInfo(accessToken);

        } catch (Exception e) {
            throw new OAuth2RestClientException("Failed to retrieve access token.");
        }
    }

(다음은 OAuth2LoginService 코드의 일부입니다.)

  • params의 oAuth2Provider와 일치하는 oAuth2ApiClientoAuth2ApiClientMap 통해서 가져옵니다.
  • oAuth2ApiClient.requestAccessToken(params) 부분이 위에서 살펴 보았던 인가 코드를 통해 토큰을 발급받는 부분입니다.
  • 최종적으로 oAuth2ApiClient.requestUserInfo(accessToken) 통해서 토큰으로 유저의 데이터를 요청합니다.

3.4 access token 으로 유저 데이터 바인딩 받기

리소스 서버로부터 받게 될 유저의 데이터를 저장하기 위한 데이터 바인딩 클래스들을 리소스 서버 문서를 참고하며 생성해 주었습니다.

OAuth2UserInfo

public interface OAuth2UserInfo {
    String getEmail();

    String getNickname();

    OAuth2Provider getOAuthProvider();

    String getId();
}

KakaoUserInfo

@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoUserInfo implements OAuth2UserInfo {

    private Long id;
    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoAccount {

        private KakaoProfile profile;
        private String email;
    }

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoProfile {

        private String nickname;
    }

    @Override
    public String getId() {
        return String.valueOf(id);
    }

    @Override
    public String getEmail() {
        return kakaoAccount.email;
    }

    @Override
    public String getNickname() {
        return kakaoAccount.profile.nickname;
    }

    @Override
    public OAuth2Provider getOAuthProvider() {
        return OAuth2Provider.KAKAO;
    }

}

NaverUserInfo

public class NaverUserInfo implements OAuth2UserInfo {

    @JsonProperty("response")
    private Response response;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class Response {

        private String id;
        private String email;
        private String nickname;
    }

    @Override
    public String getEmail() {
        return response.email;
    }

    @Override
    public String getNickname() {
        return response.nickname;
    }

    @Override
    public OAuth2Provider getOAuthProvider() {
        return OAuth2Provider.NAVER;
    }

    public String getId() {
        return response.id;
    }
}

이제 토큰과 유저의 데이터를 받을 클래스들까지 생성했으므로,
리소스 서버를 통해 유저의 데이터를 받습니다.

KakaoApiClient

@Component
@RequiredArgsConstructor
@Slf4j
public class KakaoApiClient implements OAuth2ApiClient {

    private static final String GRANT_TYPE = "authorization_code";

    @Override
    public OAuth2UserInfo requestUserInfo(String accessToken) {

        // header
        HttpHeaders httpHeaders = createUrlEncodedHttpHeaders();
        httpHeaders.set("Authorization", "Bearer " + accessToken);
        // body
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("property_keys", "[\"kakao_account.email\", \"kakao_account.profile\"]");
        //request
        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);
        //response
        ResponseEntity<String> stringResponseEntity = restTemplate.postForEntity(userInfoUri,
            request, String.class);
        log.info("API response: stringResponseEntity.getBody(): {}",
            stringResponseEntity.getBody());
        return restTemplate.postForObject(userInfoUri, request, KakaoUserInfo.class);
    }

NaverApiClient

@Component
@RequiredArgsConstructor
@Slf4j
public class NaverApiClient implements OAuth2ApiClient {

    @Override
    public OAuth2UserInfo requestUserInfo(String accessToken) {
        //header
        HttpHeaders httpHeaders = createUrlEncodedHttpHeaders();
        httpHeaders.set("Authorization", "Bearer " + accessToken);
        //body
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        //request
        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);
        //response
        ResponseEntity<String> stringResponseEntity = restTemplate.postForEntity(userInfoUri,
            request, String.class);
        log.info("API response: stringResponseEntity.getBody(): {}",
            stringResponseEntity.getBody());
        return restTemplate.postForObject(userInfoUri, request, NaverUserInfo.class);
    }

access token 을 통해 유저 정보에 대한 자원에 접근할 수 있는것을 확인할 수 있습니다.


3.5 소셜 로그인 계정 저장

이후 소셜 로그인 계정 유저를 클라이언트 서버에 저장하는 과정을 살펴보겠습니다.
(이 과정은 프로젝트마다 상이할 수 있습니다.)

OAuth2LoginService

@Service
@RequiredArgsConstructor
@Slf4j
public class OAuth2LoginService {

    private final UserRepository userRepository;
    private final JwtTokenGenerator jwtTokenGenerator;
    private final Map<OAuth2Provider, OAuth2ApiClient> oAuth2ApiClientMap;
    private final BCryptPasswordEncoder encoder;

    /**
     * @param params from OAuth2 resource server(KAKAO, NAVER...)
     * @return Access token generated JWT
     */
    public AccessToken login(OAuth2LoginParams params) {
        OAuth2UserInfo oAuth2UserInfo = requestUserInfo(params);
        return jwtTokenGenerator.generateAccessToken(findUser(oAuth2UserInfo));
    }

    private String findUser(OAuth2UserInfo oAuth2UserInfo) {
        String username = getUsernameByUserInfo(oAuth2UserInfo);

        log.info("[OAuth2LoginService] Find user by username : {}", username);

        return userRepository.findByUsername(username)
            .map(User::fromEntity)
            .map(User::getUsername)
            .orElseGet(
                () -> createUserInfo(oAuth2UserInfo, username)
            );
    }

    public OAuth2UserInfo requestUserInfo(OAuth2LoginParams params) {
        OAuth2ApiClient oAuth2ApiClient = oAuth2ApiClientMap.get(params.oAuth2Provider());
        try {
            String accessToken = oAuth2ApiClient.requestAccessToken(params);
            return oAuth2ApiClient.requestUserInfo(accessToken);

        } catch (Exception e) {
            throw new OAuth2RestClientException("Failed to retrieve access token.");
        }
    }

다음의 코드를 요약한다면 이렇습니다.

  • 리소스 서버로부터 받은 유저의 정보가 클라이언트 서버에 등록되어 있는지 확인합니다.
  • 등록되어있지 않다면 해당 계정을 클라이언트 서버에 등록합니다.
  • 최종적으로 유저의 정보를 통해 access token 을 발급하여 클라이언트 서비스를 이용할 수 있도록 만듭니다.

끝마치며

이로서 OAuth2 를 이용한 소셜 로그인 서비스를 구현하였습니다.
관련 코드를 포함한 프로젝트의 전체 코드는 [해당 깃 허브] 에서 확인하실 수 있습니다.
긴 글 읽어주셔서 감사합니다 :)

profile
보안/응용 소프트웨어 개발자

0개의 댓글