본문 바로가기

Spring/Security

[Spring Security] 소셜 로그인 과정 이해하기 (Feat. Kakao)

1. 개요

다음 프로젝트에서 카카오를 필두로 한 소셜 로그인을 구현하게 되었다. 이에 앞서 구구모 프로젝트의 코드를 이용하여 소셜 로그인의 진행 과정을 백엔드 입장에서 정리해보고자 한다.

2. 진행 과정

카카오에서 제시하는 소셜 로그인의 진행 과정은 다음과 같다.

 

이를 백엔드 입장에서 하나씩 살펴보자.

2-1 인가 코드 받기

우선 프론트 측에서 카카오 인증 서버와 연결하여 로그인을 한다. 이에 따른 인가 코드를 받아온다. 인가 코드를 받은 클라이언트는 서비스 서버에 이를 넘긴다.

2-2 토큰 받기

    @GetMapping("/kakao/login")
    public ApiResponse<String> login(@RequestParam(name = "code") String code) {

        String accessToken = kakaoService.getAccessTokenFromKakao(code);
        KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(accessToken);

        Boolean isJoined = memberService.isJoinedKakaoMember(userInfo.getId());
        Boolean isJoined = memberService.isExistUsername(userInfo.get)
        StringBuilder loginResult = new StringBuilder();

        if(isJoined) {
            loginResult.append("Bearer ").append(memberService.kakaoLogin(userInfo.getKakaoAccount().getProfile().getNickName()));
        }
        else {
            loginResult.append("not joined");
        }


        return ApiResponse.createSuccess(loginResult.toString());
    }

우선 클라이인트 측에서 프로젝트의 서버로 카카오 로그인을 요청한다. 이에 따라 서버에서는 accessToken과 userInfo를 받는다. 이 과정은 다음과 같다.

@Value("${kakao.restKey}")
private String clientId;
@Value("${kakao.secretKey}")
private String secretKey;
private String KAUTH_TOKEN_URL_HOST = "https://kauth.kakao.com";
private String KAUTH_USER_URL_HOST = "https://kapi.kakao.com";

public String getAccessTokenFromKakao(String code) {
    KakaoTokenResponseDto kakaoTokenResponseDto = WebClient.create(KAUTH_TOKEN_URL_HOST).post()
            .uri(uriBuilder -> uriBuilder
                    .scheme("https")
                    .path("/oauth/token")
                    .queryParam("grant_type", "authorization_code")
                    .queryParam("client_id", clientId)
                    .queryParam("code", code)
                    .queryParam("client_secret", secretKey)
                    .build(true))
            .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString())
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, ClientResponse-> Mono.error(new RuntimeException("Invalid Parameter")))
            .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error")))
            .bodyToMono(KakaoTokenResponseDto.class)
            .block();

    log.info(" [Kakao Service] Access Token ------> {}", kakaoTokenResponseDto.getAccessToken());
    log.info(" [Kakao Service] Refresh Token ------> {}", kakaoTokenResponseDto.getRefreshToken());
    //제공 조건: OpenID Connect가 활성화 된 앱의 토큰 발급 요청인 경우 또는 scope에 openid를 포함한 추가 항목 동의 받기 요청을 거친 토큰 발급 요청인 경우
    log.info(" [Kakao Service] Id Token ------> {}", kakaoTokenResponseDto.getIdToken());
    log.info(" [Kakao Service] Scope ------> {}", kakaoTokenResponseDto.getScope());

    return kakaoTokenResponseDto.getAccessToken();
}

accessToken을 받아오는 코드이다. https://kauth.kakao.com/oauth/token에 애플리케이션 등록 시의 클라이언트 아이디, 비밀번호, 인가 코드 등의 정보를 담아 요청, 결과 토큰을 받아온다. 

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token-response

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

이 문서에 의거한 토큰 결과 dto는 다음과 같다.

@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoTokenResponseDto {

    @JsonProperty("token_type")
    public String tokenType;
    @JsonProperty("access_token")
    public String accessToken;
    @JsonProperty("id_token")
    public String idToken;
    @JsonProperty("expires_in")
    public Integer expiresIn;
    @JsonProperty("refresh_token")
    public String refreshToken;
    @JsonProperty("refresh_token_expires_in")
    public Integer refreshTokenExpiresIn;
    @JsonProperty("scope")
    public String scope;
}

이 중 accessToken이 필요하므로 이를 리턴한다.

2-3 사용자 정보 받기

    public KakaoUserInfoResponseDto getUserInfo(String accessToken) {
        KakaoUserInfoResponseDto userInfo = WebClient.create(KAUTH_USER_URL_HOST)
                .get()
                .uri(uriBuilder -> uriBuilder
                        .scheme("https")
                        .path("/v2/user/me")
                        .build(true))
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) // access token 인가
                .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString())
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter")))
                .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error")))
                .bodyToMono(KakaoUserInfoResponseDto.class)
                .block();

        log.info("[ Kakao Service ] Auth ID ---> {} ", userInfo.getId());
        log.info("[ Kakao Service ] NickName ---> {} ", userInfo.getKakaoAccount().getProfile().getNickName());
        log.info("[ Kakao Service ] ProfileImageUrl ---> {} ", userInfo.getKakaoAccount().getProfile().getProfileImageUrl());

        return userInfo;
    }

2-2에서 받은 토큰을 헤더에 담아(RFC 7235에 의해 Bearer을 접두어로 붙여 전송) https://kapi.kakao.com/v2/user/me 요청을 보내면 해당 사용자의 정보를 받아올 수 있다.

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info-response

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

사용자 정보에 관한 구조 역시 공식 문서에 담겨있다.

@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoUserInfoResponseDto {
    //회원 번호
    @JsonProperty("id")
    private Long id;

    //서비스에 연결 완료된 시각. UTC
    @JsonProperty("connected_at")
    private Date connectedAt;

    //사용자 프로퍼티
    @JsonProperty("properties")
    private HashMap<String, String> properties;

    //카카오 계정 정보
    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @Getter
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    public class KakaoAccount {
        //닉네임 제공 동의 여부
        @JsonProperty("profile_nickname_needs_agreement")
        private Boolean isNickNameAgree;

        //프로필 사진 제공 동의 여부
        @JsonProperty("profile_image_needs_agreement")
        private Boolean isProfileImageAgree;

        //사용자 프로필 정보
        @JsonProperty("profile")
        private Profile profile;

        @Getter
        @NoArgsConstructor
        @JsonIgnoreProperties(ignoreUnknown = true)
        public class Profile {

            //닉네임
            @JsonProperty("nickname")
            private String nickName;

            //프로필 미리보기 이미지 URL
            @JsonProperty("thumbnail_image_url")
            private String thumbnailImageUrl;

            //프로필 사진 URL
            @JsonProperty("profile_image_url")
            private String profileImageUrl;

            //프로필 사진 URL 기본 프로필인지 여부
            //true : 기본 프로필, false : 사용자 등록
            @JsonProperty("is_default_image")
            private String isDefaultImage;

            //닉네임이 기본 닉네임인지 여부
            //true : 기본 닉네임, false : 사용자 등록
            @JsonProperty("is_default_nickname")
            private Boolean isDefaultNickName;

        }
    }
}

2-4 로그인 처리

사용자 정보를 가져왔으니 다시 컨트롤러 계층으로 돌아가보자.

    @GetMapping("/kakao/login")
    public ApiResponse<String> login(@RequestParam(name = "code") String code) {

        String accessToken = kakaoService.getAccessTokenFromKakao(code);
        KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(accessToken);

        Boolean isJoined = memberService.isJoinedKakaoMember(userInfo.getId());
        StringBuilder loginResult = new StringBuilder();

        if(isJoined) {
            loginResult.append("Bearer ").append(memberService.kakaoLogin(userInfo.getKakaoAccount().getProfile().getNickName()));
        }
        else {
            loginResult.append("not joined");
        }


        return ApiResponse.createSuccess(loginResult.toString());
    }

 유저 정보를 받았으니 카카오와 연동하여 할 일은 다 마쳤다. 이제 카카오 인증 서버와 할 일은 끝났다. 서버의 db에서 해당 회원이 존재하는 지 찾는다. 해당 회원이 가입되어 있는 상태라면 내부 로직을 통해 JWT를 발급하여 전송해주었고, 그렇지 않을 경우 이를 나타내어 리턴하여 클라이언트에서 추가 정보를 받아 회원 가입을 진행하였다.

 

3. 마치며

현재 하는 프로젝트에서는 여러 개의 OAuth2 클라이언트로 소셜 로그인을 구현해야 한다. 이에 따라 다음 게시글에서는 Spring OAuth2를 사용하여 더욱 편하게 이를 구현해보고자 한다.

'Spring > Security' 카테고리의 다른 글

Json 로그인 분석하기 - 스프링 시큐리티의 동작 과정  (1) 2024.11.29
Hello, Spring Security  (1) 2024.03.27