BE/API

[OAuth2.0] Spring + OAuth2.0 Kakao 소셜 로그인 구현(2)

Ezcho 2024. 1. 2. 20:38

이전글

https://ezerocho.tistory.com/62

 

[OAuth2.0] Spring + OAuth2.0 Kakao 소셜 로그인 구현(1)

이전글 https://ezerocho.tistory.com/61 [OAuth 2.0] OAuth2.0 프로토콜 동작과정 OAuth2.0, Open Authorization 2.0은 인증을 위한 개방형 표준 프로토콜 이다. 흔히들 소셜로그인이라 부르는 카카오톡로그인, Google로

ezerocho.tistory.com

 

이전글에서 AccessToken과 RefreshToken을 받아오는것까지 구현해보았다. 이제 받아온 AccessToken을 가지고 해당 사용자 정보를 불러온 후, Member 엔티티를 만들고 DB에 저장해보자.

 

1. Member Entity 구현


멤버엔티티는 아래와 같이 구현해주었다. 사실 별건없다.

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id; //auto indexing
    private String mpw; //1111으로 통일(나중에 Oauth말고, 시큐리티나 jwt로 확장할때 사용)
    private String email;   //이메일
    private String nickname;    //사용자명
    private String refreshToken;    //refreshToken은 dB에 저장
    private String profileImgUrl;   //profileImgUrl
	//...

}

다른 로그인방식의 추가를 위해 mpw객체도 하나 만들어줬다. 현재로썬 1111로 통일되게 하였다.

 

2. AccessToken으로 사용자 정보 받아오기 (요청)


사용자 정보를 보낼때 url은 좀 다르다

https://kapi.kakao.com/v2/user/me로 GET요청을 보낸다.

 

예제를 보자

아래 페이지를 먼저 읽고 오는것을 추천한다.https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info

 

Kakao Developers

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

developers.kakao.com

예제
curl -v -G GET "https://kapi.kakao.com/v2/user/me" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"

request header에 베어러 방식으로 AccessToken을 추가해서 보내는 방식이다.

특정 정보에 대한 응답만 받고싶은 경우 위 링크페이지에 자세히 나와있으니 참고하자.

 

우선 이전글에서 작성했던 코드를 보자.

@Service
public class KakaoService {
    @Autowired
    private MemberRepository memberRepository;

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String KAKAO_CLIENT_ID;
    @Value("${spring.security.oauth2.client.registration.kakao.client-secret}")
    private String KAKAO_CLIENT_SECRET;
    @Value("${spring.security.oauth2.client.registration.kakao.redirect_uri}")
    private String KAKAO_REDIRECT_URL;
	
    //...
    private final static String KAKAO_API_URI = "https://kapi.kakao.com";
    //Access TOken 받은 후 유저 정보 받기위한 도메인
	//...
    public Member getKakaoInfo(String code, HttpServletRequest request) throws Exception { // cf) alt + f7: 사용된 곳 보기
        //...
        try {
            //...
            
            HttpSession session = request.getSession();
            session.setAttribute("accessToken", accessToken);
            //session.setAttribute("refreshToken", refreshToken);
            //리프토큰은 세션에 저장X

        } catch (Exception e) {
            throw new Exception("API call failed");
        }
        return getUserInfoWithToken(accessToken, refreshToken);   //리턴해서 이제 accessToken을 파라미터로 아래 함수 호출
    }

accessToken을 받아온후 세션에 저장하고. 이후 return문에서 getUserInfoWithToken함수를 호출하게했다. 이때 두 토큰을 파라미터로 넘겨준다.

 

getUserInfoWithToken을 작성하기전에 일단 요청, 응답을 처리할 DTO를하나 만들어주었다.

import lombok.*;

@Builder
@Data

public class KakaoDTO {
    private String email;
    private String nickname;
    private String profileImgUrl;
}

 

 

 

이제 함수를 작성해보자

private Member getUserInfoWithToken(String accessToken, String refreshToken) throws Exception {
	//HttpHeader 생성
        //curl -v -X GET "https://kapi.kakao.com/v2/user/me" \
        //  -H "Authorization: Bearer ${ACCESS_TOKEN}"
        //위 양식에 따라 요청,
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
}

1. AccessToken, refreshToken을 인자로 받는다. 이후 위 함수에서 했던것처럼 http헤더 하나 만들어준다.

 

private Member getUserInfoWithToken(String accessToken, String refreshToken) throws Exception {
	//HttpHeader 생성
        //curl -v -X GET "https://kapi.kakao.com/v2/user/me" \
        //  -H "Authorization: Bearer ${ACCESS_TOKEN}"
        //위 양식에 따라 요청,
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
        
        //HttpHeader 담기, 위의 과정과 동일
        RestTemplate restTem = new RestTemplate();
        HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(headers);
        ResponseEntity<String> response = restTem.exchange(
                KAKAO_API_URI + "/v2/user/me",
                HttpMethod.POST,
                httpEntity,
                String.class
	);
}

2. 이전글과 똑같이 RestTemplate 객체를 만들어 엔티티를 하나 만들고 응답을 받는다. 달라진것은 request본문과 url밖에없다.

 

 

3. AccessToken으로 사용자 정보 받아오기 (응답)


위 카카오 개발 링크에서 예제 응답을 보자, 아래 양식에 따라response가 온다.

HTTP/1.1 200 OK
{
    "id":123456789,
    "connected_at": "2022-04-11T01:45:28Z",
    "kakao_account": { 
        // 프로필 또는 닉네임 동의항목 필요
        "profile_nickname_needs_agreement	": false,
        // 프로필 또는 프로필 사진 동의항목 필요
        "profile_image_needs_agreement	": false,
        "profile": {
            // 프로필 또는 닉네임 동의항목 필요
            "nickname": "홍길동",
            // 프로필 또는 프로필 사진 동의항목 필요
            "thumbnail_image_url": "http://yyy.kakao.com/.../img_110x110.jpg",
            "profile_image_url": "http://yyy.kakao.com/dn/.../img_640x640.jpg",
            "is_default_image":false
        },
        // 이름 동의항목 필요
        "name_needs_agreement":false, 
        "name":"홍길동",
        // 카카오계정(이메일) 동의항목 필요
        "email_needs_agreement":false, 
        "is_email_valid": true,   
        "is_email_verified": true,
        "email": "sample@sample.com",
 		//...       
    },
    "properties":{
        "${CUSTOM_PROPERTY_KEY}": "${CUSTOM_PROPERTY_VALUE}",
        ...
    },
    "for_partner": {
        "uuid": "${UUID}"
    }
}

 

이전글과 마찬가지로 JSON Parser를 통해 String객체들을 분리하는 코드를 작성해주었다.

RestTemplate restTem = new RestTemplate();
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTem.exchange(
        KAKAO_API_URI + "/v2/user/me",
        HttpMethod.POST,
        httpEntity,
        String.class
);

//Response 데이터 JSON 파싱
JSONParser jsonParser = new JSONParser();
JSONObject jsonObj = (JSONObject) jsonParser.parse(response.getBody());
JSONObject account = (JSONObject) jsonObj.get("kakao_account");
JSONObject profile = (JSONObject) account.get("profile");
System.out.println("json: "+ jsonObj);

String email = String.valueOf(account.get("email"));
String nickname = String.valueOf(profile.get("nickname"));
String profileImgUrl = String.valueOf(profile.get("profile_image_url"));

KakaoDTO kakaoUser = KakaoDTO.builder()
        .email(email)
        .nickname(nickname)
        .profileImgUrl(profileImgUrl)
        .build();
// 사용자 정보를 Member 엔티티에 저장
return saveKakaoUser(kakaoUser, refreshToken);

별건없다. 

1. 위에서 정의한 KakaoDTO를 사용하여 email, nickname, profileImageUrl을 가져와 kakaoDTO를 만들어주었다.

2. 이후 kakaoUser라는 DTO와 refreshToken을 saveKakaoUser 함수로 보내주었다.

 

 

 

3. AccessToken으로 사용자 정보 받아오기 (DB에 저장)


이제 saveKakaoUser메서드를 작성하여 받아온 정보를 멤버DB에 저장해보자.

private Member saveKakaoUser(KakaoDTO kakaoUser, String refreshToken) {
    System.out.println("save User in member repo");
    Optional<Member> existingMember = memberRepository.findByEmail(kakaoUser.getEmail()); //repo메서드로 조회
    if (existingMember.isPresent()) {
        Member member = existingMember.get();   //기존정보 get해서
        member.setNickname(kakaoUser.getNickname());    //닉네임 업뎃
        member.setRefreshToken(refreshToken);   //refreshToken업뎃
        member.setProfileImgUrl(kakaoUser.getProfileImgUrl());  //프로필 아이콘 업데이트
        return memberRepository.save(member);  //저장
    } else {
        Member newMember = Member.builder() //빌더로 설정
                .mpw("1111") // 비번1111고정
                .email(kakaoUser.getEmail()) //
                .nickname(kakaoUser.getNickname()) // 카카오 닉네임
                .refreshToken(refreshToken) // 리프레시 토큰
                //.roleSet(Collections.singleton(MemberRole.USER)) // 역할 설정
                .profileImgUrl(kakaoUser.getProfileImgUrl())
                .build();
        return memberRepository.save(newMember);
    }

}

1. email기반으로 멤버레포지토리를 사용해 DB내의 값들과 비교하며 사용하는지 판단하였다.

2. 존재한다면 기존정보를 업데이트하는 방향으로, 그렇지않다면 builder로 멤버객체를 새로만들어주었다.

3. 이후 저장하여 멤버객체 전체를 리턴했다.

4. 이렇게된다면 앞에서 호출했던 메서드들을 거슬러 컨트롤러에 도달한다.

 

서비스를 호출했던 컨트롤러를 보자.

@Controller
@RequiredArgsConstructor
@RequestMapping("kakao")
public class KakaoController {
    //로그인 페이지가 뜨고, 로그인을 하면 kakao/redirect와 &code=xxx 라는 파라미터를 보내줌,
    private final KakaoService kakaoService;
    //다시 서비스를 호출하고 path가 /redirect인넘을 만나면 컨트롤러 실행
    @GetMapping("/redirect")
    public String callback(HttpServletRequest request) throws Exception {
        //HTTP 통신을 하기에 redirect uri value도 http로 설정,
        System.out.println("auth code: "+ request.getParameter("code"));//authorization 코드 뜬거 한번 출력
        Member member = kakaoService.getKakaoInfo(request.getParameter("code"), request);
        return "redirect:/member/" + member.getId();
        //메시지 엔티티는 사용하길래 해봄
    }
}

jsp를 사용한 프로젝트고, 사용자별 페이지를 다르게 보여주기위해 member/id 값으로 리디렉트 시켰고. 다른 컨트롤러에서 해당 멤버의 id값으로 새로운 처리를 할 수 있다.

@Controller
public class MemberController {

    @Autowired
    private MemberService memberService;

    @Autowired
    private KakaoService kakaoService;
    @GetMapping("/member/{memberId}")
    public String getMemberProfile(@PathVariable int memberId, HttpServletRequest request, Model model) throws Exception {
       //...
    }
}

 

실행결과

위에서 보았던 응답양식처럼 잘 뜨는것을 볼 수 있다. 저장도 잘 된다.

 

 

 

 

4. 전체코드


KakaoService

@Service
public class KakaoService {
    @Autowired
    private MemberRepository memberRepository;

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String KAKAO_CLIENT_ID;
    @Value("${spring.security.oauth2.client.registration.kakao.client-secret}")
    private String KAKAO_CLIENT_SECRET;
    @Value("${spring.security.oauth2.client.registration.kakao.redirect_uri}")
    private String KAKAO_REDIRECT_URL;

    private static final String FRIENDS_LIST_SERVICE_URL = "https://kapi.kakao.com/v1/api/talk/friends";
    //RestAPI, 리디렉트 uri, secret 코드 각각 private 선언
    //1211 친구목록 가져오는 url 추가

    private final static String KAKAO_AUTH_URI = "https://kauth.kakao.com";
    //Authorization 코드 받기 위한 도메인
    private final static String KAKAO_API_URI = "https://kapi.kakao.com";
    //Access TOken 받은 후 유저 정보 받기위한 도메인

    //Authorization코드, 즉 인가코드를 받기위한 최초요청
    public String getKakaoLogin() {
        return KAKAO_AUTH_URI + "/oauth/authorize"
                + "?client_id=" + KAKAO_CLIENT_ID
                + "&redirect_uri=" + KAKAO_REDIRECT_URL
                + "&response_type=code";
    }
    //Flow는 여기서KakaoController 로 이동

    public Member getKakaoInfo(String code, HttpServletRequest request) throws Exception { // cf) alt + f7: 사용된 곳 보기
        //얘는 KakaoController의 호출로 넘어옴, KakaoDTO class의 함수
        if (code == null) throw new Exception("Failed get authorization code");
        //Authorization 코드를 받지 못할 경우 예외 리턴
        //추후에 받을 AccessToken과 Refresh토큰 선언
        String accessToken = "";
        String refreshToken = "";

        try {
            //Http헤더 객체 선언
            HttpHeaders headers = new HttpHeaders();
            //아래의 양식에 따라 전체 URL만드는 일련의 과정
            //AccessToken, RefreshToken 요청
            //curl -v -X POST "https://kauth.kakao.com/oauth/token" \
            // -H "Content-Type: application/x-www-form-urlencoded" \
            // -d "grant_type=authorization_code" \
            // -d "client_id=${REST_API_KEY}" \
            // --data-urlencode "redirect_uri=${REDIRECT_URI}" \
            // -d "code=${AUTHORIZE_CODE}"

            headers.add("Content-type", "application/x-www-form-urlencoded");
            MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
            params.add("grant_type", "authorization_code");
            params.add("client_id", KAKAO_CLIENT_ID);
            params.add("client_secret", KAKAO_CLIENT_SECRET);
            params.add("redirect_uri", KAKAO_REDIRECT_URL);
            params.add("code", code);

            RestTemplate restTemplate = new RestTemplate();
            //RestTemplate 클래스는, Java에서 RESTful 서비스를 소비하는 데 사용되는 클래스,
            //GET, POST, PUT, DELETE 등의 HTTP 메소드를 사용하여 요청을 보냄
            HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);
            System.out.println("httpEntity: " + httpEntity);
            //위에서 삽입했던 headers와 params를 가지고 Http Entity를 맹글어줌(URL 탄생)

            //ResponseEntity를 통해 restTemplate객체로 요청했을때 응답을 받아옴,
            ResponseEntity<String> response = restTemplate.exchange(
                    KAKAO_AUTH_URI + "/oauth/token",
                    HttpMethod.POST,    //POST요청
                    httpEntity,         //위에서만든 httpEntity객체
                    String.class        //restTemplate.exchange() 메서드를 호출할 때, 응답 본문을 String 타입의 객체로 변환
            );

            JSONParser jsonParser = new JSONParser();   //Json파서 선언
            JSONObject jsonObj = (JSONObject) jsonParser.parse(response.getBody());
            //JsonObj 선언 String객체로 변환한 response를 다시 JsonData로 변환

            accessToken = (String) jsonObj.get("access_token");    //access_token획득
            refreshToken = (String) jsonObj.get("refresh_token");   //refresh_token획득
            System.out.println("access_token: " + accessToken);      //출력함 해보고
            System.out.println("refresh_token: " + refreshToken);

            HttpSession session = request.getSession();
            session.setAttribute("accessToken", accessToken);
            //session.setAttribute("refreshToken", refreshToken);
            //리프토큰은 세션에 저장X

        } catch (Exception e) {
            throw new Exception("API call failed");
        }
        return getUserInfoWithToken(accessToken, refreshToken);   //리턴해서 이제 accessToken을 파라미터로 아래 함수 호출
    }

    private Member getUserInfoWithToken(String accessToken, String refreshToken) throws Exception {
        //HttpHeader 생성
        //curl -v -X GET "https://kapi.kakao.com/v2/user/me" \
        //  -H "Authorization: Bearer ${ACCESS_TOKEN}"
        //위 양식에 따라 요청,
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        //HttpHeader 담기, 위의 과정과 동일
        RestTemplate restTem = new RestTemplate();
        HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(headers);
        ResponseEntity<String> response = restTem.exchange(
                KAKAO_API_URI + "/v2/user/me",
                HttpMethod.POST,
                httpEntity,
                String.class
        );

        //Response 데이터 JSON 파싱
        JSONParser jsonParser = new JSONParser();
        JSONObject jsonObj = (JSONObject) jsonParser.parse(response.getBody());
        JSONObject account = (JSONObject) jsonObj.get("kakao_account");
        JSONObject profile = (JSONObject) account.get("profile");
        System.out.println("json: "+ jsonObj);

        String email = String.valueOf(account.get("email"));
        String nickname = String.valueOf(profile.get("nickname"));
        String profileImgUrl = String.valueOf(profile.get("profile_image_url"));

        KakaoDTO kakaoUser = KakaoDTO.builder()
                .email(email)
                .nickname(nickname)
                .profileImgUrl(profileImgUrl)
                .build();
        // 사용자 정보를 Member 엔티티에 저장
        return saveKakaoUser(kakaoUser, refreshToken);
    }
    private Member saveKakaoUser(KakaoDTO kakaoUser, String refreshToken) {
        System.out.println("save User in member repo");
        Optional<Member> existingMember = memberRepository.findByEmail(kakaoUser.getEmail()); //repo메서드로 조회
        if (existingMember.isPresent()) {
            Member member = existingMember.get();   //기존정보 get해서
            member.setNickname(kakaoUser.getNickname());    //닉네임 업뎃
            member.setRefreshToken(refreshToken);   //refreshToken업뎃
            member.setProfileImgUrl(kakaoUser.getProfileImgUrl());  //프로필 아이콘 업데이트
            return memberRepository.save(member);  //저장
        } else {
            Member newMember = Member.builder() //빌더로 설정
                    .mpw("1111") // 비번1111고정
                    .email(kakaoUser.getEmail()) //
                    .nickname(kakaoUser.getNickname()) // 카카오 닉네임
                    .refreshToken(refreshToken) // 리프레시 토큰
                    //.roleSet(Collections.singleton(MemberRole.USER)) // 역할 설정
                    .profileImgUrl(kakaoUser.getProfileImgUrl())
                    .build();
            return memberRepository.save(newMember);
        }

    }
}

 

HomeController

@RequiredArgsConstructor
//@Autowired대신
@Controller
public class HomeController {
    private final KakaoService kakaoService;
    @RequestMapping(value="/", method= RequestMethod.GET)
    public String login(Model model) {
        model.addAttribute("kakaoUrl", kakaoService.getKakaoLogin());

        //getKakaoLogin함수 실행, authorizatio코드 요청
        return "main";//루트 url 에서 main.jsp 리턴
    }
}

KakaoController

@Controller
@RequiredArgsConstructor
@RequestMapping("kakao")
public class KakaoController {
    //로그인 페이지가 뜨고, 로그인을 하면 kakao/redirect와 &code=xxx 라는 파라미터를 보내줌,
    private final KakaoService kakaoService;
    //다시 서비스를 호출하고 path가 /redirect인넘을 만나면 컨트롤러 실행
    @GetMapping("/redirect")
    public String callback(HttpServletRequest request) throws Exception {
        //HTTP 통신을 하기에 redirect uri value도 http로 설정,
        System.out.println("auth code: "+ request.getParameter("code"));//authorization 코드 뜬거 한번 출력
        Member member = kakaoService.getKakaoInfo(request.getParameter("code"), request);
        return "redirect:/member/" + member.getId();
        //메시지 엔티티는 사용하길래 해봄
    }
}

 

MemberController(응답후 멤버객체로 보여줄 페이지에 대한 처리)

@Controller
public class MemberController {

    @Autowired
    private MemberService memberService;

    @Autowired
    private KakaoService kakaoService;
    @GetMapping("/member/{memberId}")
    public String getMemberProfile(@PathVariable int memberId, HttpServletRequest request, Model model) throws Exception {
       //...
    }
}

 

main.jsp(WEB-INF/views)

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login Page</title>
    <link rel="stylesheet" type="text/css" href="../../resources/css/main.css">
</head>
<body>
    <div class="bg-image">
        <div class="login-container">
            <img src="<c:url value='../../resources/assets/logo.png'/>" alt="MetaKakao" class="logo">
            <div class = "main-title">TestService</div>
            <a href="${kakaoUrl}" class="login-button" id="kakaoLoginButton_Kr">
                <img src="<c:url value='../../resources/assets/kakaoLoginButton_kr.png'/>" alt="카카오 로그인">
            </a>
            <a href="${kakaoUrl}" class="login-button" id="kakaoLoginButton_En">
                <img src="<c:url value='../../resources/assets/kakaoLoginButton_en.png'/>" alt="Login with Kakao">
            </a>
        </div>
    </div>
    <div class = "image"></div>
</body>
</html>

 

다음글에서는 로그아웃과 refreshToken처리방법에 대해서 다루겠다.