프로젝트 Project

OAuth2 로그인 구현하기

달래dallae 2024. 8. 6. 11:21

0️⃣ 프로젝트 환경

  • Spring Boot 2.7
  • Gradle
  • Java 11, JPA
  • Rest API
  • Spring Security : Oauth 관련 라이브러리는 사용하지 않았습니다.
  • Oauth2 서버 : Google, Naver, Kakao

✔️ 들어가기 앞서

Spring Securtiy를 사용했지만 Oauth와 관련된 라이브러리는 사용하지 않았습니다.

Spring Security의 기능으로 구현한다면 더 쉽게 구현할 수 있지만, 저는 순수 구현을 통해 Oauth를 이해하는 것을 목표로 하였습니다.

추후에 또 Oauth를 구현한다면 관련 Security 라이브러리를 사용해 이해했던 것을 바탕으로 수월하게 구현할 수 있을 것 같습니다.

즉, 이 글은 Spring Security에 의존해 Oauth를 구현하는 글이 아닙니다.

Oauth에 대한 이해와 흐름 파악이 목적입니다.

💡 Spring Security의 Oauth2 라이브러리 사용하기
저는 직접 요청응답리다이렉트 데이터를 조작하였지만, properties.yml 설정에서 spring.security.oauth2.client.provder.... 라이브러리를 사용해 provider를 설정하면 일일이 request/redirect나 response 데이터를 신경쓰지 않아도 됩니다.
또한 OAuth2UserService라는 interface를 구현하면 Security 내부 로직을 통해 Oauth 사용자 정보를 쉽게 얻을 수 있습니다.

 

✔️ 필요한 사전 지식

  • Oauth는 토큰을 기반으로 API를 요청하기 때문에  JWT와 같은 인증 토큰에 대한 이해가 필요합니다.
  • 구현할 OAuth 서버의 공식 API 명세서를 한 번 정독하는 과정이 필요합니다. 구현하다보면 결국 “먼저 읽고 시작했으면 더 빠르게 이해할 수 있었을텐데..” 하는 생각이 듭니다.
💡 Oauth2 API 명세서
- 구글: https://developers.google.com/identity/protocols/oauth2/web-server?hl=ko
- 네이버: https://developers.naver.com/docs/login/api/api.md
- 카카오: https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api

 


1️⃣ Oauth 전체 흐름

Oauth로 사용자 정보(아이디, 이메일 등)를 가져오는 과정을 요약하자면 다음과 같습니다.

  1. Oauth 서버에 클라이언트로 등록
  2. Oauth 서버에 해당 서버에 접근하기 위한 Authorization Code를 요청
  3. Oauth 서버는 Authorization Code 응답 → Authorization Code로 사용자 정보에 대한 Access Token 요청
  4. 사용자가 로그인에 성공하면 Oauth 서버가 Access Token 응답 → Access Token으로 사용자 정보 요청
  5. (선택) 사용자 정보를 바탕으로 로그인 로직 처리db에 해당 email의 정보가 없으면 서비스 db에 insert한 뒤 access 및 refresh token 발급

 


2️⃣ Oauth 서버에 클라이언트 등록

Oauth 서비스를 이용하기 위해선 우리 서비스(나)에 대한 검증이 필요합니다.

OAuth 서버의 입장에서는 어떤 서비스에서 우리 사용자에 대한 요청을 했는지 알 필요가 있기 때문입니다.

이를 위해서 사용하려는 Oauth 서버에 클라이언트(나)에 대한 정보를 등록해야 API를 요청할 수 있습니다.

💡 Oauth 서버별 클라이언트 등록 주소
- 구글 : https://console.cloud.google.com
- 카카오 : https://developers.kakao.com/console/app
- 네이버 : https://developers.naver.com/apps/#/register?api=nvlogin

자세한 등록 방법은 검색하면 친절하게 알려주는 글이 많으므로 생략하겠습니다.

여기서 redirect uri를 등록하게 되는데, 이것은 Oauth서버가 Authorization Code를 응답할 주소입니다. 3️⃣ 에서 자세히 설명하겠습니다.

 

✔️ Oauth 관련 설정 관리

Oauth 서버 별로 클라이언트 id와 secret, 그리고 요청에 쓰이는 uri들이 많습니다.

때문에 관련 설정은 따로 yml이나 properties 파일로 관리하는 것이 좋습니다.

 


3️⃣ 로그인 페이지 요청

우리 서비스에서 어떤 사용자가 '카카오 로그인' 버튼을 클릭한다면, 카카오톡에 로그인하는 페이지로 이동하게 됩니다.

이렇게 카카오톡 로그인 요청을 했을때, 리다이렉트시킬 카카오톡의 로그인 페이지를 직접 설정해주어야 합니다.

이것을 편의상 Oauth 서버의 end point uri라고 지칭하겠습니다.

이 주소는 OAuth 서버별로 다르며, API 명세서에서 요구하는 파라미터를 알 수 있습니다.

 

💡 OAuth 서버별 end point uri
- 구글: https://accounts.google.com/o/oauth2/v2/auth
- 카카오: https://kauth.kakao.com/oauth/authorize
- 네이버: https://nid.naver.com/oauth2.0/authorize

 

💡 OAuth 서버별 end point url 예시

카카오톡
- 필수 요구파라미터가 client_id, response_type, redirect_uri입니다.
- 따라서 end pont url은 https://kauth.kakao.com/oauth/authorize?client_id={설정한클라이언트아이디}&response_type=code&redirect_uri={설정한 redirect uri} 가 됩니다.
구글
- 필수 요구파라미터가 client_id, response_type, redirect_uri, scope 입니다.
- 따라서 end point url은 https://accounts.google.com/o/oauth2/v2/auth?client_id={설정한 클라이언트 아이디}&redirect_uri={설정한 redirect uri}&response_type=code&scope={설정한 scope} 가 됩니다.

 


3️⃣ Authorization Code 요청

Authorization Code는 Oauth 사용자가 정보 접근 권한을 허용했음을 나타내는 인증 코드입니다.

OAuth 사용자가 로그인에 성공하면, OAuth 서버는 우리 서비스의 redirect uri 주소로 Authorization Code를 응답합니다.

이 때 2️⃣에서 설정해 두었던 redirect uri와 우리 서비스가 파라미터로 보내는 redirect_uri가 일치해야합니다. (일치하지 않으면 OAuth 서버는 응답을 끝내버립니다)

 

✔️ 구현 코드

더보기

Controller

@GetMapping("/login/{oAuthProvider}")
public ResponseEntity<String> oauthLogin(@PathVariable OAuthProvider oAuthProvider
) {
    String redirectUri = oAuthService.findLoginRedirectUri(oAuthProvider);
    return ResponseEntity.status(HttpStatus.FOUND)
            .location(URI.create(redirectUri))
            .build();
}

Service

@Service
public class OAuthService {
    private final Map<OAuthProvider, OAuthProviderInfo> providers;

		/*OAuthProvider를 if else로 구분하는 것은 가독성이 떨어져 매핑해서 생성자 주입 */
		public OAuthService(List<OAuthProviderInfo> providers) {
				this.providers = providers.stream().collect(
				Collectors.toUnmodifiableMap(OAuthProviderInfo::oAuthProvider, 
																			Function.identity()));
		}

		/* Oauth 서버에 따른 end point uri를 알아옵니다. */
		public String findLoginRedirectUri(OAuthProvider oAuthProvider) {
				OAuthProviderInfo oAuthProviderInfo = providers.get(oAuthProvider);
				return oAuthProviderInfo.getEndPointUrl();
		}
}

Oauth 로그인 요청을 하면, yml 파일에서 설정한 해당 OAuth서버의 로그인 페이지를 가져와 redirect합니다.

OAuthProviderInfo는 yml에서 설정한 Oauth서버의 정보를 가져오기 위해 사용하는 인터페이스입니다.

각 구현체(예: GoogleProviderInfo)에 yml 정보를 주입받습니다.

 

OAuthProvider (enum)

@RequiredArgsConstructor
public enum OAuthProvider {
    GOOGLE("google"),
    NAVER("naver"),
    KAKAO("kakao");

    private final String name;

    public static OAuthProvider ofName(String name) {
        for(OAuthProvider provider : OAuthProvider.values()) {
            if(provider.name.equalsIgnoreCase(name)) {
                return provider;
            }
        }
        return null;
    }
}

 

OAuthProviderInfo

public interface OAuthProviderInfo {
    OAuthProvider oAuthProvider();
    String getClientId();
    String getClientSecret();
    String getEndPointUrl();
    String getAuthUri();
    String getTokenUri();
    String getUserInfoUri();
    String getGrantType();
    String getState();
}

OAuthProviderInfoOAuthProviderInfo 구현체 (예:GoogleProviderInfo)

@RequiredArgsConstructor
@Component
public class GoogleProviderInfo implements OAuthProviderInfo {
    @Value("${oauth2.google.client-id}")
    private String clientId;

    @Value("${oauth2.google.client-secret}")
    private String clientSecret;

    @Value("${oauth2.google.end-point-uri}")
    private String endPointUri;

    @Value("${oauth2.google.auth-uri}")
    private String authUri;

    @Value("${oauth2.google.token-uri}")
    private String tokenUri;

    @Value("${oauth2.google.user-info-uri}")
    private String userInfoUri;

    @Value("${oauth2.google.grant-type}")
    private String grantType;

    @Value("${oauth2.google.scope}")
    private String scope;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.GOOGLE;
    }

    @Override
    public String getClientId() {
        return this.clientId;
    }

    @Override
    public String getClientSecret() {
        return this.clientSecret;
    }

    @Override
    public String getEndPointUrl() {
        return this.endPointUri
                + "?client_id=" + this.clientId
                + "&redirect_uri=" + this.authUri
                + "&response_type=code"
                + "&scope=" + UriEncoder.encode(scope);
    }

    @Override
    public String getAuthUri() {
        return this.authUri;
    }

    @Override
    public String getTokenUri() {
        return this.tokenUri;
    }

    @Override
    public String getUserInfoUri() {
        return this.userInfoUri;
    }

    @Override
    public String getGrantType() {
        return this.grantType;
    }

    @Override
    public String getState() {
        return null;
    }
}

 


4️⃣ Access Token 요청

이제 응답받은 Authorization Code를 사용해서 Oauth 사용자에 대한 Access Token 발급을 요청할 수 있습니다.

OAuth 서버의 Access Token API를 요청하면, OAuth 서버는 Access Token(및 Refresh Token 등)을 반환해줍니다.

 

✔️ Access Token

Access Token은 Oauth 서버 내에서 사용하는 Oauth 사용자에 대한 인증 토큰입니다.

Spring Security로 로그인 구현하기 에서 다루었던 Access Token과 같은 개념으로, Oauth 서버의 API를 요청 했을때 Oauth 사용자가 인증되었으며 사용자에 대한 정보(아이디 등)을 얻기 위해 사용됩니다.

💡 OAuth 서버별 Access Token 요청 API 주소
구글:  https://oauth2.googleapis.com/token
카카오: https://kauth.kakao.com/oauth/token
네이버: https://nid.naver.com/oauth2.0/token

 

✔️ 구현 코드

더보기

Controller

@GetMapping("/{oauthProvider}/authorize")
public ResponseEntity<TokenDto> OAuth2Register(@PathVariable OAuthProvider oauthProvider,
                                               @RequestParam(name = "code") String authorizationCode) {
    return ResponseEntity.ok(oAuthService.login(oauthProvider, authorizationCode));
}

OAuth 서버 모두 code 라는 형태로 Authorization Code를 응답받습니다.

 

Service

@Service
public class OAuthService {
    private final MemberRepository memberRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final Map<OAuthProvider, OAuthProviderInfo> providers;
    private final Map<OAuthProvider, OAuthApiClient> clients;

    public OAuthService(MemberRepository memberRepository,
                        JwtTokenProvider jwtTokenProvider,
                        List<OAuthProviderInfo> providers,
                        List<OAuthApiClient> clients) {
        this.memberRepository = memberRepository;
        this.jwtTokenProvider = jwtTokenProvider;
        this.providers = providers.stream().collect(Collectors.toUnmodifiableMap(OAuthProviderInfo::oAuthProvider, Function.identity()));
        this.clients = clients.stream().collect(Collectors.toUnmodifiableMap(OAuthApiClient::oAuthProvider, Function.identity()));
    }

    /* OAuth 로그인한다. */
    public TokenDto login(OAuthProvider oAuthProvider, String authorizationCode) {
        OAuthApiClient client = clients.get(oAuthProvider);
        OAuthProviderInfo providerInfo = providers.get(oAuthProvider);
        String accessToken = client.requestAccessToken(providerInfo, authorizationCode);
        OAuthUserInfo oAuthUserInfo = client.requestUserInfo(providerInfo, accessToken);

        // kakao의 경우 비즈니스 인증이 되어야 email을 가져올 수 있도록 변경되었다.
        // email이 없을 경우 회원가입 처리
        Member member = memberRepository.findByEmail(oAuthUserInfo.getEmail())
                .orElse(memberRepository.save(Member.registerByOauth(oAuthUserInfo.getEmail(),
                                                                        oAuthUserInfo.getNickname(),
                                                                        oAuthUserInfo.getOAuthProvider(),
                                                                        oAuthUserInfo.getOAuthId())));

        // jwt 토큰을 발급한다.
        return jwtTokenProvider.generate(member.getEmail(), member.getRole().toString());
    }
}

 

OAuthApiClient

public interface OAuthApiClient {
    OAuthProvider oAuthProvider();
    String requestAccessToken(OAuthProviderInfo oAuthProviderInfo, String authorizationCode);
    OAuthUserInfo requestUserInfo(OAuthProviderInfo providerInfo, String accessToken);
}

 

OAuthApiClient 구현체 (KakaoApiClient)

@RequiredArgsConstructor
@Component
public class KakaoApiClient implements OAuthApiClient {
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    @Override
    public String requestAccessToken(OAuthProviderInfo providerInfo, 
																     String authorizationCode
    ) {
        final HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        body.add("grant_type", providerInfo.getGrantType());
        body.add("client_id", providerInfo.getClientId());
        body.add("client_secret", providerInfo.getClientSecret());
        body.add("redirect_uri", providerInfo.getAuthUri());

        final HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
        KakaoTokens response = restTemplate.postForObject(providerInfo.getTokenUri(), request, KakaoTokens.class);
        return response.getAccessToken();
    }
}

Rest Template을 사용해서 api를 따로 작성하지 않고 요청/응답을 처리했습니다.

 

Access Token DTO (KakaoTokens)

모두 JSON으로 응답하는데, 그 형태가 조금씩 달라서 저는 Oauth 서버마다 응답 토큰을 직렬화할 dto를 생성해주었습니다.

@Getter
@NoArgsConstructor
public class KakaoTokens {
    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("token_type")
    private String tokenType;

    @JsonProperty("refresh_token")
    private String refreshToken;

    @JsonProperty("expires_in")
    private String expiresIn;

    @JsonProperty("refresh_token_expires_in")
    private String refreshTokenExpiresIn;

    @JsonProperty("scope")
    private String scope;
}

 


5️⃣ 사용자 정보 요청

이제 이 Access Token으로 상세한 사용자의 정보 (이메일, 닉네임..)를 요청할 수 있습니다.

Access token을 이용해서 사용자의 정보를 응답하는 API를 요청해야 합니다.

이에 관한 정책은 OAuth 서버마다 다르므로 직접 API 명세서를 통해 확인해야 합니다. (예를 들어 카카오의 경우 비즈니스 등록을 하지 않으면 닉네임 외에 어떤 정보도 알아올 수 없도록 변경되었습니다.)

또한 OAuth 서버에서는 요청한 사용자 정보를 JSON으로 응답하는데, 그 형태도 OAuth서버마다 다르므로 Token Dto와 마찬가지로 사용자 정보에 대한 Dto도 각각 구현해주었습니다.

 

✔️ 구현 코드

API 컨트롤러/서비스는 4️⃣와 동일합니다. 사용자 정보 요청에 대한 실제 로직은 아래의 OauthApiClient에서 이루어졌습니다.

더보기

OAuthApiClient(인터페이스)를 구현한 GoogleApiClient

@RequiredArgsConstructor
@Component
public class GoogleApiClient implements OAuthApiClient {
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.GOOGLE;
    }

    @Override
    public OAuthUserInfo requestUserInfo(OAuthProviderInfo providerInfo, String accessToken) {
        String url = providerInfo.getUserInfoUri()
                + "?access_token=" + accessToken;

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        HttpEntity<?> request = new HttpEntity<>(body);
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class, request);
        String responseBody = response.getBody();

        try {
            return objectMapper.readValue(responseBody, GoogleOAuthUserInfo.class);
        } catch (JsonProcessingException e) {
            throw new OAuth2Exception("GoogleOAuthUserInfo 직렬화 실패");
        }
    }
}

마찬가지로 RestTemplate를 이용해서 사용자 정보에 대한 응답을 받아왔습니다.

 

OAuthUserInfo

public interface OAuthUserInfo {
    String getEmail();
    String getNickname();
    OAuthProvider getOAuthProvider();
    String getOAuthId();
}

Rest Template로 받은 응답을 직렬화할 인터페이스입니다.

 

OAuthUserInfo(인터페이스)를 구현한 KakaoOauthUserInfo

@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoOAuthUserInfo implements OAuthUserInfo {
    @JsonProperty("id")
    private String 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;
//        private String phone_number;
    }

    @Override
    public String getEmail() {
        return "kakaoEmail" + UUID.randomUUID() +"@kakaomail.com";
    }

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

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

    @Override
    public String getOAuthId() {
        return this.id;
    }
}

 


6️⃣ 회원가입/로그인 처리

이후 회원가입이나 로그인은 자유롭게 커스텀할 수 있습니다.

저는 Oauth 회원과 자체 회원을 구분하여 저장소에 Oauth 회원의 정보를 넣어주는 방법을 선택했습니다.

따라서 Oauth 로그인 시 저장소에 회원에 대한 정보가 있으면 JWT 토큰을 발급해 로그인 처리, 없으면 회원가입 후 로그인처리를 해주었습니다.

 

✔️ 구현 코드

더보기

OauthAPIController

@GetMapping("/login/{oAuthProvider}")
public ResponseEntity<String> oauthLogin(@PathVariable OAuthProvider oAuthProvider) {
    String redirectUri = oAuthService.findLoginRedirectUri(oAuthProvider);
    return ResponseEntity.status(HttpStatus.FOUND)
            .location(URI.create(redirectUri))
            .build();
}

@GetMapping("/{oauthProvider}/authorize")
public ResponseEntity<TokenDto> OAuth2Register(@PathVariable OAuthProvider oauthProvider,
                                               @RequestParam(name = "code") String authorizationCode) {
    return ResponseEntity.ok(oAuthService.login(oauthProvider, authorizationCode));
}

 

OatuhService

public TokenDto login(OAuthProvider oAuthProvider, String authorizationCode) {
    OAuthApiClient client = clients.get(oAuthProvider);
    OAuthProviderInfo providerInfo = providers.get(oAuthProvider);
    String accessToken = client.requestAccessToken(providerInfo, authorizationCode);
    OAuthUserInfo oAuthUserInfo = client.requestUserInfo(providerInfo, accessToken);

    // email이 없을 경우 회원가입 처리
    Member member = memberRepository.findByEmail(oAuthUserInfo.getEmail())
            .orElse(memberRepository.save(Member.registerByOauth(oAuthUserInfo.getEmail(),
                                                                    oAuthUserInfo.getNickname(),
                                                                    oAuthUserInfo.getOAuthProvider(),
                                                                    oAuthUserInfo.getOAuthId())));

    // jwt 토큰을 발급한다.
    return jwtTokenProvider.generate(member.getEmail(), member.getRole().toString());
}

kakao의 경우 비즈니스 인증을 거쳐야 email을 가져올 수 있도록 변경되었기 때문에, 현재 저의 프로젝트에서는 로그인/회원가입 처리가 정상적으로 작동하지 않습니다.