카카오로 로그인 - 스프링, jsp, 오라클, mybatis

2024. 4. 7. 19:56카테고리 없음

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api

카카오에서 안내해준 이 document 를 보고 구현해보았다. 

 

javascript 로 하는 방법과 REST API 를 혼합해서 사용했다. 
왜냐하면, REST API 를 사용해서 하려 했는데, 인가코드를 달라고 GET요청했더니, html 파일을 내려주었기 때문이다. 
받은 html 파일을 어떻게 잘 해서 사용자에게 렌더링해줄수도 있었겠지만, "이렇게 하는게 맞나..?" 라는 생각이 들었기 때문이다.  

 

어쨋든, 끔찍한 혼종일지 아니면 괜찮은 방법일지에 대해서는 댓글로 남겨주세요 ^_^

 

또한 아래 글은, Oauth 에 대한 공부를 하지 않은 채로 카카오 로그인 연동을 구현한 시도이기 때문에, 정확한 개념들을 잘 설명하지 못하고 있다는 점을 미리 말씀드립니다. 
어떻게든 카카오 연동로그인을 시도해보려고 하는 분들에게 조금이라도 도움이 되었으면 합니다. 
혼란을 가중시키는 건 아니겠죠..? 혼란을 가중시켜봅시다 ㅋㅋ 시작합니다

 

----------------------------------------------------------------------------------------------------------------------

1단계 : 카카오로그인 연동을 위해서는 좀 밑작업이 필요하다. 

아까 안내한 페이지의 네브바에 보면, 아래와 같이 내 애플리케이션 이라는 항목이 있다. 클릭하면 아래와 같이 나온다. 

애플리케이션 추가하기 클릭해서 내 애플리케이션 추가하기. 회사 없으니까, 그냥 프로젝트명이랑 동일하게 적었던것같다. 

그리고 나서, 자신의 프로젝트명이 아래에 나오게 되면 클릭해서 들어가면 된다. 

그럼 첫 화면이 아래와 같을텐데, 요약정보 탭에 보면, 앱키 라는 게 있는데, 이거 굉장히 중요하다. 나중에 쓸것이다. 기억해두길.

 

그리고, 아래에서 보듯 플랫폼 탭을 들어가보면, 사이트 도메인을 설정하는 게 있는데, 내 프로젝트는 아직 배포를 안했기 때문에, 
http://localhost:8080 으로 해두었다.

 

이렇게 해둔 다음에, 해야 할건 위 이미지에서 네브바를 보면, 문서 탭이 있다. 들어가보자. 
그럼, 아래와 같이 카카오 로그인 탭 안에 REST API 라는 탭이 있는데, 엄청길다. 
이 긴 document 에 처음 부분에 보면, 아래와 같은 게 나오는데, 여기서, 2개의 표 중에 밑에걸 보면, 사전 설정 에 해줘야 할 것들이 적혀져 있다. 누르면 링크로 자세하고 친절하고 쉽게 따라할 수 있도록 안내되어 있으니 그대로 따라하면 된다. 

 

다른 건 그대로 따라하면 되고, 내 프로젝트 같은 경우 Redirect URL 등록을 아래와 같이 해주었다. 

 

밑작업 끝. 

이제, 러프하게 내가 했던 과정들에 대해 설명해보자면, 아래와 같다. 

카카오 로그인을 클릭한다.

그럼 아래와 같은 페이지가 나오게 된다. 

위 페이지에서 로그인을 하면, 인가코드(카카오 서버에게 사용자에 대한 데이터를 달라고 요청하는 POST 요청 보낼 때 사용됨)가 우리가 redirectURL 로 설정한 곳(내 프로젝트 같은 경우 http://localhost:8080/get-auth-code )으로 온다. 

 

여기까지 일단 진행해본다. 

 

 

 

---------------------------------------------------------------------------------------------------------------------

2단계 : 

인가코드를 받는것 까지 자바스크립트를 이용하였다. 아래와 같이 카카오 로그인 버튼이 있는 페이지에 추가할 요소들을 써두겠다. 

<head>
<!-- kakao 로그인 관련 -->
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.1/kakao.min.js"
    integrity="sha384-kDljxUXHaJ9xAb2AzRd59KxjrFjzHa5TAoFQ6GbYTCAG0bjM55XohjjDT7tDDC01"
    crossorigin="anonymous"></script>
<script>
    Kakao.init('앱키 중 자바스크립트 키'); // 사용하려는 앱의 JavaScript 키 입력
    console.log(Kakao.isInitialized());
</script>

</head>
<body>

    <a id="kakao-login-btn" href="javascript:loginWithKakao()">
        <img src="https://k.kakaocdn.net/14/dn/btroDszwNrM/I6efHub1SN5KCJqLm1Ovx1/o.jpg"
            width="222" alt="카카오 로그인 버튼" />
    </a>
    
    <script>
        function loginWithKakao() {
            Kakao.Auth.authorize({
                redirectUri: 'http://localhost:8080/get-auth-code',
            });
        }
    </script>
</body>

Kakao.init('앱키 중 자바스크립트 키'); 이 부분이 중요한데, 

아까 중요하다고 했던 앱 키 중 자바스크립트 키를 써주면 된다. 

그럼 이제, 카카오 버튼을 클릭하면, 자동으로 아래와 같은 페이지가 나오게 된다. 
원래 로그인 버튼 누르면, 이용약관 동의하는 페이지였는데, 지금은 왜인지 모르겠으나, 생략되었다. 

어쨋든, 로그인을 마치고 나면, redirectURL 로 설정해줬던, http://localhost:8080/get-auth-code 이라는 url 로 HTTP응답메세지가 올 것이다. 이를 받을 컨트롤러를 아래와 같이 만들어주었다. 
여기서, @RequestParam 으로 받은 code 에 담기는 것은, 사용자가 로그인함으로써 카카오서버가 내 애플리케이션에 내려준 "인가코드"이다. 이를 이용해서 카카오서버에게 사용자에 대한 정보를 달라고 POST 요청을 보낼 수 있다. 앞으로 "인가코드" 라고 하면 이걸 말하는 걸로 알면 된다. 

@RequestMapping("/get-auth-code")
public String getAuthCode(@RequestParam("code") String code, Model model, 
                           HttpServletRequest request){

    log.info("code=============={}", code);
    // code에는 뭐가 들어왔을까? MvMX-FLIi4ulZqhlxLhGshltYfWuujoStaKSkXs3.. 엄청 길었는데 이것만 남겨둠. 

    // 이걸 통해 HTTP요청을 카카오 서버에 보낸다(토큰달라는 요청임)
    Map<String, String> returnMap = getKakaoTokenService.getKakaoToken(code);
}

 

GetKakaoTokenService 클래스의 getKakaoToken 메서드를 보자. 

그전에, 아래 라이브러리들을 build.gradle 에 추가시켜줘야 함. 

	// Auth0 JWT 라이브러리 추가 (카카오 로그인 관련)
	implementation 'com.auth0:java-jwt:3.19.0'

	// JWKs를 위한 RSA 라이브러리 추가 (카카오 로그인 관련해서 공개키를 가져오기 위하여 추가함)
	implementation 'com.auth0:jwks-rsa:0.21.1'

진짜 GetKakaoTokenService 클래스의 getKakaoToken 메서드를 보자. 일단 전체 클래스는 접은 글과 같이 생겼다. 어쩌구저쩌구 라고 쓰여있는 것들은 제 정보를 보호하기 위함입니다..

더보기
import com.auth0.jwk.*;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;
import com.fasterxml.jackson.databind.ObjectMapper;
import firstportfolio.wordcharger.DTO.KakaoTokenResponseDTO;
import firstportfolio.wordcharger.exception.login.DecodingException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.nio.charset.StandardCharsets;
import java.security.interfaces.RSAPublicKey;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

@Slf4j
@RequiredArgsConstructor
@Service
public class GetKakaoTokenService {
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    public Map<String,String> getKakaoToken(String code){
        String url = "https://kauth.kakao.com/oauth/token";

        //HTTP 헤더 설정
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
        map.add("grant_type", "authorization_code");
        map.add("client_id", "9a9c9b어쩌구저쩌구");
        map.add("redirect_uri", "http://localhost:8080/get-auth-code");
        map.add("code", code);

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

        KakaoTokenResponseDTO response = restTemplate.postForObject(url, entity, KakaoTokenResponseDTO.class);

        log.info("response={}", response);
        //response 에 들어온 것 중 JWT 는 id_token 에 들어온 것 뿐이다.
        String idToken = response.getId_token();
        log.info("response.id_token={}", idToken);
        // id_token 만 꺼내보면 아래와 같다. JWT 구성요소로는 헤더(Header), 페이로드(Payload), 서명(Signature) 가 있다.
        // 헤더 : eyJraWQiOiI5ZjI1MmRhZGQ1ZjIzM2Y5M2QyZmE1MjhkMTJmZWEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9
        // 페이로드 : .eyJhdWQiOiI5YTljOWIyNzZmMzNjMGIwMGU1NjAzNGM4NWNhZDRmYiIsInN1YiI6IjM0MjU0MDUwMjEiLCJhdXRoX3RpbWUiOjE3MTI0NTM4NzMsImlzcyI6Imh0dHBzOi8va2F1dGgua2FrYW8uY29tIiwibmlja25hbWUiOiJ3b3duczU5MCIsImV4cCI6MTcxMjQ3NTQ3MywiaWF0IjoxNzEyNDUzODczfQ
        // 서명 : .GDwhiYAbEWSt4uEU8F27hPPcntVQEnCCQN7mjfON3clFxPweSaEx-mfTNepL3nCyqnsvXeX-P44Gbg6lQkly9mP56bImbQ02czXWRKk43l8jCRHv3g-UAd0lxCvzKgWwpL2Rd4GZjDhUyWMmEhFe0SVJWiK8hVQY4EKZdaEsrjKwRve0pwbaElrO1ZueQ4BQRruOm_GL0fZTljQybqPUMTcQeh0vVWNJbYKB3Yc8JLY-0CPkgjKx8y0Rczl6PnN0svcgT5xPM4air5znoY-fS7_OLquES5feesubTzbu0sSZN_1km2xJ8jOEHJ7WpKfA-RoTXkMCeZBdazSvNEA6PQ
        // 위와 같이 . 을 기준으로 헤더 페이로드 서명 이 구분된다.

        Map<String, String> returnMap = new ConcurrentHashMap<>();


        try {
            // 페이로드를 디코딩하기 전에, 일단 서명이 올바른지에 대해 검증해야함.
            DecodedJWT jwtOrigin = JWT.decode(idToken);
            log.info("키 아이디 =========={}", jwtOrigin.getKeyId()); // 밑에서 사용될건데, https://kauth.kakao.com 에서 가져온 공개키 들 중 어떤 공개키인지를 구분할 수 있게 해주는 아이디 역할.
            log.info("토큰=========={}", jwtOrigin.getToken()); // <= id_token(JWT) 의 헤더 페이로드 서명 모두가 나오더라.
            log.info("헤더=========={}", jwtOrigin.getHeader());  // 헤더만 나오던데.
            log.info("페이로드=========={}", jwtOrigin.getPayload()); // 페이로드만 나옴.
            log.info("서명=========={}", jwtOrigin.getSignature()); // 서명만 나옴.
            // 이걸 통해 DecodedJWT 타입 객체에 대해 좀 알게 되었다.
            // JWT 라는 static 클래스의 decode 메서드를 호출하면서 JWT 를 매개변수로 넘겨주면,
            // 매개변수로 받은 JWT 를 분석해서 그 JWT 에 대한 정보를 바인딩한 DecodedJWT 라는 객체를 리턴해주는 구나.

            // 2. 공개키 프로바이더 준비
            // 	implementation 'com.auth0:jwks-rsa:0.21.1' 라고 build.gradle 에 추가해줬잖아.
            //  이 라이브러리의 역할은, 서버(https://kauth.kakao.com)로부터 HTTP GET 요청을 보내서 공개키들을 얻어오는 역할을 한다.
            //  가져온 공개키들은 JwkProvider 타입의 객체에 담기게 된다.
            JwkProvider provider = new JwkProviderBuilder("https://kauth.kakao.com")
                    .cached(10, 7, TimeUnit.DAYS) // 캐싱 설정. 왜? 카카오로그인 할 때마다, HTTP요청 보내서 공개키 가져오지 말고, 그냥 캐싱에 7일간 담아두고 꺼내 쓰겠다는 뜻. 10은 한번에 캐싱할 수 있는 공개키의 최대 개수.
                    .build();
            // 공개키들 중 JWT(idToken) 에 담겨있는 keyId 와 일치하는 keyId 를 가진 공개키를 꺼내온다.
            // 이때, Jwk 타입 객체는 공개키와 관련된 데이터를 담는 객체라고 보면 된다.
            Jwk jwk = provider.get(jwtOrigin.getKeyId());

            // 3. 검증 및 디코딩
            Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null); // 공개키 객체에서 publicKey 를 이용하여 알고리즘 객체를 만든다.
            JWTVerifier verifier = JWT.require(algorithm).build(); // 이렇게 하면 알고리즘객체를 이용해서 JWTVerifier 라는 서명검증기계인 JWTVerifier 타입 객체가 나온다고 생각하면 된다.
            DecodedJWT jwt = verifier.verify(idToken); // 서명검증기계를 이용해서, 카카오서버가 준 idToken(JWT) 에 들어있는 서명이 올바른지를 통해(카카오의 공개키와 같은지를 통해) 내가 받은 JWT가 카카오가 보낸것인지를 검증하는 것이다.
            // 만약 서명이 일치하지 않는다면? JwkException 이라는 예외가 발생하게 되어있다. catch 문에서 처리.

            //------------------------------------------------------------------------

            //여기까지 왔다는 건, 서명이 일치한다는 뜻.
            // JWT 중 페이로드 디코딩하기 시작

            Map<String, Object> payload = decodeJWT(response.getId_token());

            if (payload != null && payload.get("sub") != null) {
                //payload 라는 Map 자료구조에 뭐가 들어있나 보자.
                log.info("payload={}", payload);
                //payload={
                //aud=9a9c9b어쩌구저쩌구, <= 내 앱키 중 REST API 키.
                //sub=34어쩌구저쩌구, <= 이게 뭐냐면, 카카오서버에서 사용자별로 부여한 고유한 번호임. 이걸 이용해서 아이디를 만들면 될듯.
                //auth_time=1712453912, <= 사용자가 카카오 로그인을 통해 인증을 완료한 시각.
                //iss=https://kauth.kakao.com, <= ID토큰(JWT의 종류 중 사용자에 대한 데이터를 담고 있는 JWT)를 발급한 인증기관 정보로서, https://kauth.kakao.com 으로 고정되어 있음.
                //nickname=wowns590, <= 카카오에서의 nickname 임.
                //exp=1712475512, <= 발급받은 토큰의 만료 시간.
                // iat=1712453912 <= 토큰의 발급 시각.
                //}

                // 위 데이터 중 뭐 써야될까?
                String sub = (String) payload.get("sub"); // <= 앞에 kakao_ 를 붙여서 사용자의 아이디이자 비밀번호로 만들거임.
                String userId = "kakao_" + sub;
                String password = sub;
                String userName = (String) payload.get("nickname"); // <= 사용자의 이름으로 사용할 것.

                returnMap.put("userId", userId);
                returnMap.put("password", password);
                returnMap.put("userName", userName);

            } else {
                // JWT 디코딩 중 오류가 발생해서 payload 에 null 이 왔다는 거거든?
                // 어떻게 해줘야 할까?
                // 사용자정의 예외 클래스를 하나 만들고 그 클래스로 만든 예외를 발생시켜주자
                throw new DecodingException();
            }

        } catch (JwkException e) {
            log.error("GetKakaoTokenService.java - 카카오 서명 검증 중 예외 발생. 서명 불일치 ", e);
            e.printStackTrace();
            returnMap.put("signatureError", "true");
            return returnMap;
        } catch (DecodingException e) {
            returnMap.put("decodingError", "true");
            return returnMap;
        }

        return returnMap;
    }

    private Map<String, Object> decodeJWT(String jwtToken) {

        // 카카오 서버에서 부터 시작해보자.
        // 카카오 서버에서 JWT(Json Web Token) JSON 형태의 문자열을 바이트배열로 변환한 다음, 그 바이트배열을 Base64 방식으로 문자열로 인코딩해서 나한테 준것.
        // 나는 String 타입 필드인 id_token 으로 그 문자열을 받았다.
        // 그래서 이제 뭘 해줘야 하냐면,
        // 1단계 : 그 문자열을 다시 바이트배열로 디코딩해줘야 함.
        // 2단계 : 바이트 배열을 다시 JSON 형식의 문자열로 변환해줘야 함.

        try {
            String[] parts = jwtToken.split("\\."); // 정규표현식에서 '.' 와 같은 특수문자를 표현하기 위한 이스케이프가 '\\' 거든 결론적으로, '\\.' 라고 하면, '.' 을 기준으로 split 하라는 말이다
            String payload = parts[1];
            // 1단계
            byte[] decode = Base64.getUrlDecoder().decode(payload); //문자열을 바이트배열로 디코딩
            // 2단계
            String decoded = new String(decode, StandardCharsets.UTF_8); // UTF_8 방식으로 다시 데이터 형식을 문자열로 변환.

            // 현재 decoded 는 JSON 형태의 문자열이다. log 를 찍어봤더니, 아래와 같았다.
            // {
            // "aud":"9a9c9b어쩌구저쩌구",
            // "sub":"342어쩌구저쩌구",
            // "auth_time":1712461060,
            // "iss":"https://kauth.kakao.com",
            // "nickname":"wowns590",
            // "exp":1712482660,
            // "iat":1712461060
            // }
            // 이런 JSON 문자열을 꺼내서 쓰기에는 굉장히 불편하잖아. 어떻게 sub 라는 키에 담긴 값을 꺼내올건데?
            // 그래서,
            // 이러한 JSON 형태의 문자열을 자바 자료구조인 Map 이라든지, 아니면 자바 객체(내가 DTO객체 만들어야겠지) 로 변환시켜주려면
            // ObjectMapper 의 readValue 를 이용하면 된다. 아래애서는 Map자료구조로 그냥 받는 거임.

            return objectMapper.readValue(decoded, Map.class);

        } catch (Exception e) {
            log.error("Failed to decode JWT", e);
            return null;
        }
    }

}

제일 먼저 해야 할 건, 카카오서버에게 사용자에 대한 정보를 달라고 해야 한다. 

RestTemplate 을 이용해서 HTTP Post 요청을 보냈다. 

String url = "https://kauth.kakao.com/oauth/token";

//HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("grant_type", "authorization_code");
map.add("client_id", "9a9c9b어쩌구저쩌구");
map.add("redirect_uri", "http://localhost:8080/get-auth-code");
map.add("code", code);

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

KakaoTokenResponseDTO response = restTemplate.postForObject(url, entity, KakaoTokenResponseDTO.class);
        
log.info("response={}", response);
//response 에 들어온 것 중 JWT 는 id_token 에 들어온 것 뿐이다.
String idToken = response.getId_token();
log.info("response.id_token={}", idToken);
// id_token 만 꺼내보면 아래와 같다. JWT 구성요소로는 헤더(Header), 페이로드(Payload), 서명(Signature) 가 있다.
// 헤더 : eyJraWQiOiI5ZjI1MmRhZGQ1ZjIzM2Y5M2QyZmE1MjhkMTJmZWEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9
// 페이로드 : .eyJhdWQiOiI5YTljOWIyNzZmMzNjMGIwMGU1NjAzNGM4NWNhZDRmYiIsInN1YiI6IjM0MjU0MDUwMjEiLCJhdXRoX3RpbWUiOjE3MTI0NTM4NzMsImlzcyI6Imh0dHBzOi8va2F1dGgua2FrYW8uY29tIiwibmlja25hbWUiOiJ3b3duczU5MCIsImV4cCI6MTcxMjQ3NTQ3MywiaWF0IjoxNzEyNDUzODczfQ
// 서명 : .GDwhiYAbEWSt4uEU8F27hPPcntVQEnCCQN7mjfON3clFxPweSaEx-mfTNepL3nCyqnsvXeX-P44Gbg6lQkly9mP56bImbQ02czXWRKk43l8jCRHv3g-UAd0lxCvzKgWwpL2Rd4GZjDhUyWMmEhFe0SVJWiK8hVQY4EKZdaEsrjKwRve0pwbaElrO1ZueQ4BQRruOm_GL0fZTljQybqPUMTcQeh0vVWNJbYKB3Yc8JLY-0CPkgjKx8y0Rczl6PnN0svcgT5xPM4air5znoY-fS7_OLquES5feesubTzbu0sSZN_1km2xJ8jOEHJ7WpKfA-RoTXkMCeZBdazSvNEA6PQ
// 위와 같이 . 을 기준으로 헤더 페이로드 서명 이 구분된다.

Map<String, String> returnMap = new ConcurrentHashMap<>();

가장 먼저, grant_type 은 항상 authorization_code 로 해주면 된다. 
그리고, client_id 는 앱 키 중 REST_API 키를 넣어주면 된다. 위에서 중요하다고 했었던 그거 맞다. 
redirect_uri 에는 redirect_uri 로 정해줬던 거 쓰면 된다. 
마지막 code 에는 사용자가 로그인함으로써 카카오 서버가 내 애플리케이션 서버에 내려준 인가코드를 적어주면 된다. 

 

응답을 받는 KakaoTokenResponseDTO 라는 타입은 아래와 같이 만들었다. 

@Data
public class KakaoTokenResponseDTO {

    private String access_token;
    private String token_type;
    private String refresh_token;
    private String id_token;
    private int expires_in;
    private String scope;
    private int refresh_token_expires_in;

}

이어서 설명하자면, log.info("response={}", response); 이렇게 response 에 담긴 값은 무엇일지 로그로 찍어보니 아래와 같이 생겼었다. 

response=KakaoTokenResponseDTO(

access_token=KJ6pPb9어쩌구저쩌구,

token_type=bearer,

refresh_token=jKPT0yfn1ZdTC0DIAZcZB1HtxTHVFf-jqKUKPXRoAAABjreu7XyBPKUF0hG4dQ,

id_token=

eyJraWQiOiI5ZjI1MmRhZGQ1ZjIzM2Y5M2QyZmE1MjhkMTJmZWEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9

.eyJhdWQiOiI5YTljOWIyN어쩌구저쩌구

.Di4mcr5RrsFz어쩌구저쩌구,

expires_in=21599,

scope=openid profile_nickname,

refresh_token_expires_in=5183999)

이 중에 중요한 게 id_token 이라는 건데, 이게 뭐냐면, JWT(Json Web Token)이다.
 

카카오 서버에서 JSON 형식의 문자열을 바이트배열로 변환시킨 후, 그 바이트배열을 다시 문자열로 해서 나한테 보내준 것이다. 

그럼 거꾸로, 그 문자열을 바이트배열로 바꾸고, 그 바이트배열을 다시 문자열로 바꾼다면? 원래의 데이터를 볼 수 있겠지. 

 

마지막으로 Map<String, String> returnMap = new ConcurrentHashMap<>();

이 부분은, 컨트롤러에게 넘겨줄 Map 자료구조를 미리 만들어 둔 것이다. 

 

이제 아래코드를 보자.

        try {
            // 페이로드를 디코딩하기 전에, 일단 서명이 올바른지에 대해 검증해야함.
            DecodedJWT jwtOrigin = JWT.decode(idToken);
            log.info("키 아이디 =========={}", jwtOrigin.getKeyId()); // 밑에서 사용될건데, https://kauth.kakao.com 에서 가져온 공개키 들 중 어떤 공개키인지를 구분할 수 있게 해주는 아이디 역할.
            log.info("토큰=========={}", jwtOrigin.getToken()); // <= id_token(JWT) 의 헤더 페이로드 서명 모두가 나오더라.
            log.info("헤더=========={}", jwtOrigin.getHeader());  // 헤더만 나오던데.
            log.info("페이로드=========={}", jwtOrigin.getPayload()); // 페이로드만 나옴.
            log.info("서명=========={}", jwtOrigin.getSignature()); // 서명만 나옴.
            // 이걸 통해 DecodedJWT 타입 객체에 대해 좀 알게 되었다.
            // JWT 라는 static 클래스의 decode 메서드를 호출하면서 JWT 를 매개변수로 넘겨주면,
            // 매개변수로 받은 JWT 를 분석해서 그 JWT 에 대한 정보를 바인딩한 DecodedJWT 라는 객체를 리턴해주는 구나.

            // 2. 공개키 프로바이더 준비
            // 	implementation 'com.auth0:jwks-rsa:0.21.1' 라고 build.gradle 에 추가해줬잖아.
            //  이 라이브러리의 역할은, 서버(https://kauth.kakao.com)로부터 HTTP GET 요청을 보내서 공개키들을 얻어오는 역할을 한다.
            //  가져온 공개키들은 JwkProvider 타입의 객체에 담기게 된다.
            JwkProvider provider = new JwkProviderBuilder("https://kauth.kakao.com")
                    .cached(10, 7, TimeUnit.DAYS) // 캐싱 설정. 왜? 카카오로그인 할 때마다, HTTP요청 보내서 공개키 가져오지 말고, 그냥 캐싱에 7일간 담아두고 꺼내 쓰겠다는 뜻. 10은 한번에 캐싱할 수 있는 공개키의 최대 개수.
                    .build();
            // 공개키들 중 JWT(idToken) 에 담겨있는 keyId 와 일치하는 keyId 를 가진 공개키를 꺼내온다.
            // 이때, Jwk 타입 객체는 공개키와 관련된 데이터를 담는 객체라고 보면 된다.
            Jwk jwk = provider.get(jwtOrigin.getKeyId());

            // 3. 검증 및 디코딩
            Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null); // 공개키 객체에서 publicKey 를 이용하여 알고리즘 객체를 만든다.
            JWTVerifier verifier = JWT.require(algorithm).build(); // 이렇게 하면 알고리즘객체를 이용해서 JWTVerifier 라는 서명검증기계인 JWTVerifier 타입 객체가 나온다고 생각하면 된다.
            DecodedJWT jwt = verifier.verify(idToken); // 서명검증기계를 이용해서, 카카오서버가 준 idToken(JWT) 에 들어있는 서명이 올바른지를 통해(카카오의 공개키와 같은지를 통해) 내가 받은 JWT가 카카오가 보낸것인지를 검증하는 것이다.
            // 만약 서명이 일치하지 않는다면? JwkException 이라는 예외가 발생하게 되어있다. catch 문에서 처리.

 

JWT 는 세 개의 부분으로 구성되어 있다. 헤더, 페이로드, 서명. 

JWT 가 오면, 일단 그게 정말 카카오서버가 보낸 게 맞는지 서명의 진위를 따져봐야 한다. 

주석에 설명이 잘 쓰여져 있어서 따로 언급할만한 건 없고, 만약 서명이 일치하지 않는다면 JwkException 예외가 발생하기 때문에, 그걸 catch문으로 잡으면서 특정 메세지를 컨트롤러에 넘길 returnMap 이라는 자료구조에 담는 방식으로 진행했다. 

 

다음 부분을 보자.

//------------------------------------------------------------------------

//여기까지 왔다는 건, 서명이 일치한다는 뜻.
// JWT 중 페이로드 디코딩하기 시작

Map<String, Object> payload = decodeJWT(response.getId_token());

if (payload != null && payload.get("sub") != null) {
    //payload 라는 Map 자료구조에 뭐가 들어있나 보자.
    log.info("payload={}", payload);
    //payload={
    //aud=9a9c9b어쩌구저쩌구, <= 내 앱키 중 REST API 키.
    //sub=34어쩌구저쩌구, <= 이게 뭐냐면, 카카오서버에서 사용자별로 부여한 고유한 번호임. 이걸 이용해서 아이디를 만들면 될듯.
    //auth_time=1712453912, <= 사용자가 카카오 로그인을 통해 인증을 완료한 시각.
    //iss=https://kauth.kakao.com, <= ID토큰(JWT의 종류 중 사용자에 대한 데이터를 담고 있는 JWT)를 발급한 인증기관 정보로서, https://kauth.kakao.com 으로 고정되어 있음.
    //nickname=wowns590, <= 카카오에서의 nickname 임.
    //exp=1712475512, <= 발급받은 토큰의 만료 시간.
    // iat=1712453912 <= 토큰의 발급 시각.
    //}

    // 위 데이터 중 뭐 써야될까?
    String sub = (String) payload.get("sub"); // <= 앞에 kakao_ 를 붙여서 사용자의 아이디이자 비밀번호로 만들거임.
    String userId = "kakao_" + sub;
    String password = sub;
    String userName = (String) payload.get("nickname"); // <= 사용자의 이름으로 사용할 것.

    returnMap.put("userId", userId);
    returnMap.put("password", password);
    returnMap.put("userName", userName);

} else {
    // JWT 디코딩 중 오류가 발생해서 payload 에 null 이 왔다는 거거든?
    // 어떻게 해줘야 할까?
    // 사용자정의 예외 클래스를 하나 만들고 그 클래스로 만든 예외를 발생시켜주자
    throw new DecodingException();
}

decodeJWT 라는 메서드는 같은 클래스에 있는 메서드로서 아래 접은글과 같이 생겼는데, JWT 에서 페이로드를 뽑아서 Map 자료구조에 넣는 기능을 하는 메서드이다. 그래서 페이로드가 든 Map 자료구조를 가지고 이제 sub(카카오서버에서 회원마다 부여한 고유번호)라든지 nickName 같은 걸 꺼내서, 컨트롤러에 넘겨줄 Map 자료구조(returnMap)에 담아주는 거임.

더보기
    private Map<String, Object> decodeJWT(String jwtToken) {

        // 카카오 서버에서 부터 시작해보자.
        // 카카오 서버에서 JWT(Json Web Token) JSON 형태의 문자열을 바이트배열로 변환한 다음, 그 바이트배열을 Base64 방식으로 문자열로 인코딩해서 나한테 준것.
        // 나는 String 타입 필드인 id_token 으로 그 문자열을 받았다.
        // 그래서 이제 뭘 해줘야 하냐면,
        // 1단계 : 그 문자열을 다시 바이트배열로 디코딩해줘야 함.
        // 2단계 : 바이트 배열을 다시 JSON 형식의 문자열로 변환해줘야 함.

        try {
            String[] parts = jwtToken.split("\\."); // 정규표현식에서 '.' 와 같은 특수문자를 표현하기 위한 이스케이프가 '\\' 거든 결론적으로, '\\.' 라고 하면, '.' 을 기준으로 split 하라는 말이다
            String payload = parts[1];
            // 1단계
            byte[] decode = Base64.getUrlDecoder().decode(payload); //문자열을 바이트배열로 디코딩
            // 2단계
            String decoded = new String(decode, StandardCharsets.UTF_8); // UTF_8 방식으로 다시 데이터 형식을 문자열로 변환.

            // 현재 decoded 는 JSON 형태의 문자열이다. log 를 찍어봤더니, 아래와 같았다.
            // {
            // "aud":"9a9c9b어쩌구저쩌구",
            // "sub":"342어쩌구저쩌구",
            // "auth_time":1712461060,
            // "iss":"https://kauth.kakao.com",
            // "nickname":"wowns590",
            // "exp":1712482660,
            // "iat":1712461060
            // }
            // 이런 JSON 문자열을 꺼내서 쓰기에는 굉장히 불편하잖아. 어떻게 sub 라는 키에 담긴 값을 꺼내올건데?
            // 그래서,
            // 이러한 JSON 형태의 문자열을 자바 자료구조인 Map 이라든지, 아니면 자바 객체(내가 DTO객체 만들어야겠지) 로 변환시켜주려면
            // ObjectMapper 의 readValue 를 이용하면 된다. 아래애서는 Map자료구조로 그냥 받는 거임.

            return objectMapper.readValue(decoded, Map.class);

        } catch (Exception e) {
            log.error("Failed to decode JWT", e);
            return null;
        }
    }

그리고 payload 라는 Map자료구조 자체가 null 이거나, sub 라는 key 가 null 값을 가지고 있다면, DecodingException 을 내도록 하였다. DecodingException 는 내가 만든 예외클래스인데, 아래와 같이 단순하다. 

public class DecodingException extends RuntimeException {
    public DecodingException() {
    }
    public DecodingException(String message) {
        super(message);
    }
}

 

그래서, 결론적으로 서명을 확인해봤더니 카카오서버가 준게 아닐 경우, returnMap 에 "signatureError" 라는 키에 "true" 를 담았고, payload 디코딩 시도했는데, 중간에 오류가 난 경우, returnMap 에 "decodingError" 라는 키에 "true" 를 담았다. 또한, 서명도 정상적이고 디코딩도 정상적으로 수행되었다면, 아래 코드가 returnMap 에 담기도록 하였다.

// 위 데이터 중 뭐 써야될까?
String sub = (String) payload.get("sub"); // <= 앞에 kakao_ 를 붙여서 사용자의 아이디이자 비밀번호로 만들거임.
String userId = "kakao_" + sub;
String password = sub;
String userName = (String) payload.get("nickname"); // <= 사용자의 이름으로 사용할 것.

returnMap.put("userId", userId);
returnMap.put("password", password);
returnMap.put("userName", userName);

 

그래서, returnMap 을 리턴받은 컨트롤러는? 

아래와 같다. 

    @RequestMapping("/get-auth-code")
    public String getAuthCode(@RequestParam("code") String code, Model model, HttpServletRequest request){
        log.info("code=============={}", code);
        // code에는 뭐가 들어왔을까? MvMX-FLIi4ulZqhlxLhGshltYfWuujoStaKSkXs3Aqpzr2yHBtHvjd-WT-IKKwzSAAABjrQ-Sx1tZc76WqiBKA
        // 이걸 통해 HTTP요청(토큰달라는 요청임)을 카카오 서버에 보낸다

        Map<String, String> returnMap = getKakaoTokenService.getKakaoToken(code);

        if (returnMap.get("signatureError") != null || returnMap.get("decodingError") != null) {
            //서명이 일치하지 않았거나, 도착한 JWT 디코딩하다가 에러가 난 경우
            model.addAttribute("kakaoError", "인증 과정 중 오류발생");
            return "redirect:/login-form";
        }

        // 뭘 해줘야 하냐면, 이미 이전에 카카오톡으로 로그인해서 회원가입이 된 경우가 있는지 확인해줘야 해.
        String userId = returnMap.get("userId");
        String password = returnMap.get("password");
        String userName = returnMap.get("userName");

        // 이메일로 그런 작업을 처리하고 싶었지만, 이메일 얻으려면 사업자 등록 하고 심사받아야한다고 해서 아이디로 MEMBER테이블에 동일한 아이디가 있는지 확인
        MemberJoinDTO findMember = memberMapper.findMemberById(userId);

        HttpSession session = request.getSession(true);


        if (findMember != null) {
            // 동일한 아이디가 있는 경우 == 이전에 카톡으로 로그인해서 내 애플리케이션에 회원가입이 되어 있는 경우
            // 세션객체에 로그인한 사용자의 데이터를 담아준다.
            session.setAttribute("loginedMember", findMember);
            return "redirect:/";

        } else{
            // 동일한 아이디가 없는 경우 == 내 애플리케이션에서 처음으로 카톡 로그인한 경우
            // 회원가입 진행 + 세션객체에 로그인한 사용자의 데이터를 담아준다.
            Integer sequence = memberMapper.selectNextSequenceValue();
            memberMapper.insertMember(sequence, userId, password, userName);
            // 나머지 주소라든지, 핸드폰번호같은 건 필요할 때 받도록 하면 됨.
            session.setAttribute("loginedMember", findMember);
            return "redirect:/";
        }





    }

 

 

아래 코드부터 시작한다. 

        if (returnMap.get("signatureError") != null || returnMap.get("decodingError") != null) {
            //서명이 일치하지 않았거나, 도착한 JWT 디코딩하다가 에러가 난 경우
            model.addAttribute("kakaoError", "인증 과정 중 오류발생");
            return "redirect:/login-form";
        }

 

만약, returnMap 에 "signatureError" 라는 키가 null 아니거나, "decodingError" 라는 키가 null 이 아닌 경우, 

즉, 서명이 일치하지 않았거나, JWT 를 디코딩하다가 에러가 난 경우, kakaoError 라는 키에 "인증 과정 중 오류발생" 이라는 문자열을 담아서 로그인 폼으로 보냈다. 로그인 폼(loginForm.jsp)에서는 아래와 같이 처리했다. 

    <!-- 게시물을 수정할 수 없는 사람이 수정을 시도했을 경우에만 띄워질 alert 창 -->
        <% if (request.getAttribute("kakaoError") != null) { %>
        <script>
            // alert 창으로 메시지 띄우기
            alert("<%= request.getAttribute("kakaoError") %>");
        </script>
        <% } %>
    <!-- ---------------------------- -->

 

이어서 다음부분은 아래와 같다. 

        // 뭘 해줘야 하냐면, 이미 이전에 카카오톡으로 로그인해서 회원가입이 된 경우가 있는지 확인해줘야 해.
        String userId = returnMap.get("userId");
        String password = returnMap.get("password");
        String userName = returnMap.get("userName");

        // 이메일로 그런 작업을 처리하고 싶었지만, 이메일 얻으려면 사업자 등록 하고 심사받아야한다고 해서 아이디로 MEMBER테이블에 동일한 아이디가 있는지 확인
        MemberJoinDTO findMember = memberMapper.findMemberById(userId);

        HttpSession session = request.getSession(true);


        if (findMember != null) {
            // 동일한 아이디가 있는 경우 == 이전에 카톡으로 로그인해서 내 애플리케이션에 회원가입이 되어 있는 경우
            // 세션객체에 로그인한 사용자의 데이터를 담아준다.
            session.setAttribute("loginedMember", findMember);
            return "redirect:/";

        } else{
            // 동일한 아이디가 없는 경우 == 내 애플리케이션에서 처음으로 카톡 로그인한 경우
            // 회원가입 진행 + 세션객체에 로그인한 사용자의 데이터를 담아준다.
            Integer sequence = memberMapper.selectNextSequenceValue();
            memberMapper.insertMember(sequence, userId, password, userName);
            // 나머지 주소라든지, 핸드폰번호같은 건 필요할 때 받도록 하면 됨.
            session.setAttribute("loginedMember", findMember);
            return "redirect:/";
        }

네이버 로그인할 때에는 사업자등록을 안해도 이메일 주소를 줘서 그걸로 내 MEMBER 테이블에 그러한 이메일 주소가 있는지 여부를 검사해서 있으면 회원가입한 적이 있다고 판단해서 "회원가입한 이력이 있으니 그 아이디로 로그인해달라"고 한다거나 등의 처리를 했었는데, 카카오 같은 경우 사업자 등록을 하고 나서 카카오에게 검사 받아야 되서 못했다.

그래서, 지금은 카카오톡으로 로그인한 사용자가 처음인 경우 "kakao_" + sub 이런 아이디로 회원가입되게 하였기 때문에(참고로 sub 는 카카오서버에서 회원별로 부여한 식별번호라고 생각하면 된다) 
MEMBER 테이블에서 그러한 아이디로 행을 조회해와서(MemberJoinDTO findMember = memberMapper.findMemberById(userId); )

그게 null 이 아니면 => 음~ 카카오톡으로 이전에 로그인해서 회원가입처리해줬구나? => 그 행을 세션객체에 담고, 홈으로 돌아가도록 함.

null 이면? 아~ 카톡으로 처음 로그인했구나? => 카카오 서버가 준 데이터를 가지고 MEMBER테이블에 INSERT 해주고, 바로 조회해서 세션객체에 담아준 다음 홈으로 리다이렉트.

 

끝.