BE 공부/Spring

[Spring] 카카오 OAuth 소셜로그인 구현(사용자 추가 동의 포함)

꼬질꼬질두부 2024. 6. 27. 00:13

클라이언트 측은 리액트를 사용한다.

이에 SpringBoot에서 OAuth 방식으로 RestApi 앱키를 이용해 로그인을 구현했다.

사실 카카오 로그인은 꽤 여러번 구현 경험이 있었지만

1. 매번마다 소셜로그인 방식이 달랐고(OAuth, SDK 등),

2. 예전에는 사용자 이메일을 필수 동의 항목으로 받아와 데이터베이스 pk값으로 두는 방식으로 개발을 했었는데,

현재는 사용자 이메일을 받아올 수 없고 사용자 카카오Id를 받아오는 방식으로 바뀌며 기존에 쓰던 카카오 로그인 코드 재활용을 못하게 됐고

3. 오늘 카카오 로그인을 구현하며 배운 새로운 사실은..

사용자에게 동의 항목을 받아올 때, 추가 항목이 있다면( 나는 알림톡 기능을 구현하고자 알림톡 전송, 캘린더 일정 생성 등 추가 동의 항목을 설정했다. ) 기존 카카오 로그인 코드에 몇 줄 더 코드를 작성해야한다..

따라서 매 번 카카오 로그인을 하며 헤매는 것 같다ㅠ

먼저 프론트엔드와 백엔드가 카카오 소셜로그인을 어떻게 하는지 대충 설명을 해보자면,

1. 프론트에서 카카오 로그인하기 버튼을 누르면

" https://kauth.kakao.com/oauth/authorize?client\_id=카카오디벨로퍼앱키&redirect\_uri=리다이렉트URI설정해둔거&response\_type=code&scope=profile\_nickname,profile\_image,talk\_message,talk\_calendar"

와 같은 링크로 이동한다.

 

2. 그러면 프론트에서 아래 사진 속 url로 리다이렉트되는데, 이때 프론트는 code 정보를 받을 수 있다.

http://localhost:8080/user/kakao/callback?code=a92U-0r5QR8zf8j1Oi7mSOLe-tlzyj5YfU7

 

! 근데 여기서 code는 일회용짜리이다.

 

만약 백엔드에서 " https://kauth.kakao.com/oauth/authorize?client_id=카카오디벨로퍼앱키&redirect_uri=리다이렉트URI설정해둔거&response_type=code&scope=profile_nickname,profile_image,talk_message,talk_calendar" 여기서 바로 redirectUrl 통해서 code로 로그인되도록 하면, 웹 프론트에서 해당 로그인 정보를 못받아오는 것 같다..

 

나도 당연히 위 url 접속하면아래와 같은 식으로  웹 페이지 안에 보여지길래 다 된 건줄 알았는데, 안받아진다해서 code 넘겨받는 api를 추가적으로 만들었었다.

 

{
    "isSuccess": true,
    "code": 1000,
    "message": "요청에 성공했습니다.",
    "result": {
        "accessToken": "Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiYXV0aCI6IiIsImlkIjoxLCJleHAiOjE3MTk0NTM1OTJ9.5pzpsYoocSTISZ3YKbUUM5ESvrch9D8vDD1GBH3WtnJQAVxPwpiHbEpeR639-dokfz_j_Qxb8TpyHsg3gGCP6g",
        "member": {
            "memberId": 1,
            "kakaoId": "3597235263",
            "nickname": "강예은",
            "profileImage": "http://k.kakaocdn.net/dn/bFicwo/btsHqatBCj8/yalsqIbiLt9iA1KAsKWIZK/img_640x640.jpg",
            "points": 5000,
            "studies": null
        }
    }

 

3. 아무튼 프론트에서 카카오 로그인 url 접속 후 받은 code 값을, 인가코드로 로그인 혹은 회원가입 하는 API에 넘겨준다.

 

4. 백엔드에서는 받은 인가 코드로 카카오에 액세스 토큰을 요청한다.

그러면 백엔드는 요청값으로 받은 액세스 토큰으로 카카오 API를 호출해 사용자 정보를 가져온다.

 

그러면 로그인 완성~

 

위 과정을 코드로 보여주자면

1. applicationProperties 설정 & 카카오톡 디벨로퍼 들어가서 redirect uri 설정

내가 헤맸던 부분은 바로 applicationProperties 값 제대로 설정하기였다.

# Kakao
kakao.client.id=abcdefghijkl
kakao.redirect.uri=http://localhost:3000/user/kakao/callback
kakao.scope = profile_nickname,profile_image,talk_message,talk_calendar

위와 같이 설정해주면되는데,

  1. kakaoClientId에는 REST API 앱키를 넣어줬다.
  2. redirectURI에는 localhost:3000로 url을 넣어줬다.
  1. scope에 사용자 추가 동의 포함하기
    • 만약 추가 동의 항목들 설정 안했으면 이거 없어도 된다. 근데 설정했는데 이거 안하면 로그인 작동 안된다.. 자꾸 동의항목 다시 설정하라는 에러뜬다ㅠ
    • 자기가 추가 동의 체크한 부분은 카카오 디벨로퍼 api 문서 확인해서 profile_nickname, profile_image,t alk_message, talk_calendar 이런식으로 나열하면 된다.

2. Controller_프론트에서 넘겨받은 인가코드로 카카오로그인하는 로직 만들기!

//Controller Code

import com.project.aistudy.dto.user.kakao.KaKaoOAuthToken;
import com.project.aistudy.dto.user.login.LoginResult;
import com.project.aistudy.service.user.KakaoService;
import com.project.aistudy.utils.baseResponse.BaseResponse;
import com.project.aistudy.utils.baseResponse.BaseResponseStatus;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class KakaoController {

    private static final Logger log = LoggerFactory.getLogger(KakaoController.class);
    private final KakaoService kakaoService;

    @GetMapping("/user/kakao/token")
    public ResponseEntity<BaseResponse<LoginResult>> kakaoCallback(@RequestParam String code) {
        KaKaoOAuthToken token = kakaoService.getKakaoToken(code);
        LoginResult result = kakaoService.loginWithAccessToken(token.getAccess_token());

        return ResponseEntity.ok(new BaseResponse<>(BaseResponseStatus.SUCCESS, result));
    }

}

 

3. Service에서는
1) 인가 코드로 액세스토큰받기 2) 액세스토큰으로 카카오톡정보받기 3) 받은 정보로 로그인 혹은 회원가입

//Service code
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.project.aistudy.dto.user.kakao.KaKaoOAuthToken;
import com.project.aistudy.dto.user.kakao.OAuthProfile;
import com.project.aistudy.dto.user.login.LoginResult;
import com.project.aistudy.entity.Member;
import com.project.aistudy.repository.user.KakaoRepository;
import com.project.aistudy.utils.baseResponse.BaseException;
import com.project.aistudy.utils.baseResponse.BaseResponseStatus;
import com.project.aistudy.config.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.Optional;

@Service
@RequiredArgsConstructor
@Slf4j
public class KakaoService {

    private final RestTemplate restTemplate;
    private final KakaoRepository kakaoRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Value("${kakao.client.id}")
    private String clientId;

    @Value("${kakao.redirect.uri}")
    private String redirectUri;

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

    //카카오인가 토큰으로 카톡 액세스토큰받기
    public KaKaoOAuthToken getKakaoToken(String code) {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");
            MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
            params.add("grant_type", "authorization_code");
            params.add("client_id", clientId);
            params.add("redirect_uri", redirectUri);
            params.add("code", code);
            params.add("scope", scope);
            HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(params, headers);
            ResponseEntity<String> response = restTemplate.exchange(
                    "https://kauth.kakao.com/oauth/token",
                    HttpMethod.POST,
                    kakaoTokenRequest,
                    String.class
            );

            ObjectMapper objectMapper = new ObjectMapper();
            KaKaoOAuthToken kaKaoOAuthToken = objectMapper.readValue(response.getBody(), KaKaoOAuthToken.class);
            return kaKaoOAuthToken;
        } catch (JsonProcessingException e) {
            throw new BaseException(BaseResponseStatus.GET_OAUTH_TOKEN_FAILED);
        }
    }

    //카카오액세스토큰으로 로그인하기
    public LoginResult loginWithAccessToken(String accessToken) {
        OAuthProfile profile = getUserProfile(accessToken);
        Optional<Member> member = kakaoRepository.findByKakaoId(profile.getId());

        if (member.isEmpty()) {
            member = Optional.of(signUp(profile));
        }

        String token = jwtTokenGenerator(member.get().getMemberId(), member.get().getKakaoId());
        LoginResult loginResult = new LoginResult();
        loginResult.setAccessToken(token);
        loginResult.setMember(member.get());
        return loginResult;
    }

    //받은 액세스토큰으로 사용자 정보 받아오기
    public OAuthProfile getUserProfile(String accessToken) {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.add("Authorization", "Bearer " + accessToken);

            HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);

            ResponseEntity<String> response = restTemplate.exchange(
                    "https://kapi.kakao.com/v2/user/me",
                    HttpMethod.GET,
                    request,
                    String.class
            );

            ObjectMapper objectMapper = new ObjectMapper();
            OAuthProfile profile = new OAuthProfile();
            profile.setId(objectMapper.readTree(response.getBody()).get("id").asText());
            profile.setConnected_at(objectMapper.readTree(response.getBody()).get("connected_at").asText());
            profile.setProperties(objectMapper.readValue(objectMapper.readTree(response.getBody()).get("properties").toString(), OAuthProfile.Properties.class));

            return profile;
        } catch (JsonProcessingException e) {
            throw new Error("Failed to get user profile", e);
        }
    }

    //회원가입 로직
    public Member signUp(OAuthProfile profile) {
        Member newMember = new Member();
        newMember.setKakaoId(profile.getId());
        newMember.setNickname(profile.getProperties().getNickname());
        newMember.setProfileImage(profile.getProperties().getProfile_image());
        kakaoRepository.save(newMember);

        return newMember;
    }

    //jwt 토큰 생성
    public String jwtTokenGenerator(Long id, String kakaoId) {
        Authentication authentication = new UsernamePasswordAuthenticationToken(id, kakaoId);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String jwt = jwtTokenProvider.createToken(authentication);

        return "Bearer " + jwt;
    }
}

 

4. Repository

//Repository code
import com.project.aistudy.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface KakaoRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByKakaoId(String kakaoId);
}

 

그리고 서비스에서 사용한 Dto들도 보여주자면

5. Dto

import lombok.Data;

@Data
public class KaKaoOAuthToken {
    private String token_type;
    private String access_token;
    private Integer expires_in;
    private String refresh_token;
    private Integer refresh_token_expires_in;
    private String scope;
}

 

 

import lombok.Data;

@Data
public class KakaoUserDto {
    private String id;
    private String nickname;
    private String profileImage;
}

 

 

import lombok.Data;

@Data
public class OAuthProfile {
    private String id;
    private String connected_at;
    private Properties properties;
    private KakaoAccount kakao_account;

    @Data
    public static class Properties {
        private String nickname;
        private String profile_image;
        private String thumbnail_image;
    }

    @Data
    public static class KakaoAccount {
        private boolean profile_nickname_needs_agreement;
        private boolean profile_image_needs_agreement;
        private Profile profile;

        @Data
        public static class Profile {
            private String nickname;
            private String thumbnail_image_url;
            private String profile_image_url;
            private boolean is_default_image;
            private boolean is_default_nickname;
        }
    }
}

 

아무튼 바로 얼마전에 했던 프로젝트 카카오 소셜로그인 코드가 똑같은데 작동을 안해서 왜 안되지 한참 고민했는데 scope 문제였다..

한번도 scope을 param으로 넘겨줬던 적이 없어서 그런게 있는 줄도 몰랐는데 이번 기회에 api 문서 보니깐 scope말고도 여러개 있어서 갈 길이 멀구나 싶었다. 화이팅