본문 바로가기
기술/Spring-Boot

[SpringBoot/OAuth2] WebSecurity 없이 REST API 환경에서 OAuth2 인증 구현하기 - 1. 카카오 로그인

by Zabee52 2022. 1. 2.

OAuth2

참고로 이건 내가 WebSecurity를 쓰기 싫어서 이러는게 아니라 쓸 줄을 몰라서 방법을 찾은 것이다..... WebSecurity 쓰는게 더 편해보이니 아는사람은 순순히 쓰도록 하는것이 좋을지도 모르고 아님말고 난 몰르고

 

몰?름

 

내 현재 목적을 말해주자면, WebSecurity 없이 네이버 소셜 로그인 코드를 짜는 것이다. 그리고 이를 통해 OAuth2 인증 절차를 깨우치는 것이다.

 

인터넷 강의를 보면서 얻은 코드스니펫은 카카오 소셜 로그인을 WebSecurity 없이 매뉴얼하게 OAuth2 절차를 진행하는 코드였다. 현재로써 나는 WebSecurity를 이용해 적용하는 방법을 사용할 수 없었다. 인터넷엔 WebSecurity에서 제공하는 OAuth2 기능을 사용하는 내용이 주류였는데, 나는 현재 실전프로젝트 중이기 때문에 이런 것을 바꾸거나 적용해볼 여유가 없었다. 그래서 주어진 상황 속에서 기능을 구현해야만 했다.

 

문제는 나는 이 소셜 로그인 코드의 흐름은 알지만, 내부의 상세한 구조를 모른다는 것이다. 내가 모르는 코드를 응용해야 하는 상황이 된 것이다. 근데 그게 가능한가? 어림도 없지. "본인"은 "이해"하고 "코드"를 "응용"할 것이다. 나는 그렇게 "선언" 했다.

그런 의미에서, 지금부터 OAuth2 절차를 좀 파악해봐야 겠다.

 

준비물은 세 가지다. 코드스니펫, 카카오 로그인 공식문서, 네이버 로그인 공식문서. 출발.

 

 

0. 준비

 

 

[Spring Boot] OAuth2 소셜 로그인 가이드 (구글, 페이스북, 네이버, 카카오)

스프링부트를 이용하여 구글, 페이스북, 네이버, 카카오 OAuth2 로그인 구현하는 방법에 대해서 소개합니다.

deeplify.dev

 

API 클라이언트 코드 및 시크릿 코드를 얻는 방법은 상단의 블로그에 아주 잘 나와 있으니 이 링크를 통해 먼저 기본적인 세팅을 하길 바란다. Callback URL까지 설정했다면 준비 완료다.

 

위 포스트에 흥미를 가져서 전부 읽어본 사람이라면 알겠지만, 아직은 흥미가 크지 않아 전부 읽어보지 않은 사람을 위해 전체적인 흐름을 아주 짤막하게 요약하자면

 

ex) 카카오

1. 프론트엔드에서 카카오 인증을 위한 URL로 요청(AJAX, Axios 이런거 안 된다.)

형식 : https://kauth.kakao.com/oauth/authorize?client_id=your_code&redirect_uri=your_callback_url&response_type=code

 

2. 카카오는 인증 정보가 유효하면 사용자가 지정한 Callback URL로 인가코드가 포함된 URL 전달

형식 : http://{CallbackURL}?code=인가코드

 

3. 백엔드는 적절한 절차를 통해 인가 코드로 카카오에 사용자의 엑세스 토큰 발급 요청. 성공시 사용자의 액세스 토큰 발급

주소 : https://kauth.kakao.com/oauth/token

 

4. 발급 받은 액세스 토큰으로 다시 사용자의 정보 가져오기 요청

주소 : https://kapi.kakao.com/v2/user/me

 

5. 가져온 정보로 로그인 처리(여기부턴 알아서 하시면 됨)

 

 

다음과 같은 절차로 인증을 진행하게 되는데, 그 중 이 글에서 다루는 것은 것은 3번, 4번 과정을 수행하는 방법이다. 그럼이제출발

 

 

코드스니펫

REST API 환경에서는 인가 코드를 이미 전달 받았다는 가정 하에 진행된다. 프론트 엔드에서 인가 코드를 성공적으로 발급받아 백엔드에 요청을 성공한 이후의 처리를 진행하는 것이다. 처리 코드는 다음과 같다.

 

더보기
@Service
@RequiredArgsConstructor
public class KakaoUserService {
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;

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

    public ResponseEntity<KakaoUserResponseDto> kakaoLogin(String code) throws JsonProcessingException {
        // 1. "인가 코드"로 "액세스 토큰" 요청
        String accessToken = getAccessToken(code);

        // 2. "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
        KakaoUserInfoDto snsUserInfoDto = getKakaoUserInfo(accessToken);

        // 3. "카카오 사용자 정보"로 필요시 회원가입  및 이미 같은 이메일이 있으면 기존회원으로 로그인
        User kakaoUser = registerKakaoOrUpdateKakao(snsUserInfoDto);

        // 4. 강제 로그인 처리
        final String AUTH_HEADER = "Authorization";
        final String TOKEN_TYPE = "BEARER";

        String jwt_token = forceLogin(kakaoUser); // 로그인처리 후 토큰 받아오기
        HttpHeaders headers = new HttpHeaders();
        headers.set(AUTH_HEADER, TOKEN_TYPE + " " + jwt_token);
        KakaoUserResponseDto kakaoUserResponseDto = KakaoUserResponseDto.builder()
                .result("로그인 성공")
                .token(TOKEN_TYPE + " " + jwt_token)
                .build();
        System.out.println("kakao user's token : " + TOKEN_TYPE + " " + jwt_token);
        System.out.println("LOGIN SUCCESS!");
        return ResponseEntity.ok()
                .headers(headers)
                .body(kakaoUserResponseDto);
    }

    private String getAccessToken(
            String code
    ) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP Body 생성
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", clientId);
        body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
        // https://kauth.kakao.com/oauth/authorize?client_id=your_code&redirect_uri=http://localhost:8080/oauth/kakao/callback&response_type=code
        body.add("code", code);


        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        return jsonNode.get("access_token").asText();
    }

    private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoUserInfoRequest,
                String.class
        );

        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        Long id = jsonNode.get("id").asLong();
        String nickname = jsonNode.get("properties")
                .get("nickname").asText();

        return new KakaoUserInfoDto(id, nickname);
    }

    private User registerKakaoOrUpdateKakao(
            KakaoUserInfoDto kakaoUserInfoDto
    ) {
        User sameUser = userRepository.findByKakaoId(kakaoUserInfoDto.getId())
                .orElse(null);

        if (sameUser == null) {
            return registerKakaoUserIfNeeded(kakaoUserInfoDto);
        } else {
            return updateKakaoUser(sameUser, kakaoUserInfoDto);
        }
    }

    private User registerKakaoUserIfNeeded(
            KakaoUserInfoDto kakaoUserInfoDto
    ) {
        // DB 에 중복된 Kakao Id 가 있는지 확인
        Long kakaoId = kakaoUserInfoDto.getId();
        User kakaoUser = userRepository.findByKakaoId(kakaoId)
                .orElse(null);
        if (kakaoUser == null) {
            // 회원가입
            // username: random UUID
            String username = "KAKAO" + UUID.randomUUID();

            // username: kakao nickname
            String nickname = kakaoUserInfoDto.getNickname();

            // password: random UUID
            String password = UUID.randomUUID().toString();
            String encodedPassword = passwordEncoder.encode(password);

            kakaoUser = User.builder()
                    .username(username)
                    .password(encodedPassword)
                    .nickname(nickname)
                    .kakaoId(kakaoId)
                    .build();
            userRepository.save(kakaoUser);
        }
        return kakaoUser;
    }

    private User updateKakaoUser(
            User sameUser,
            KakaoUserInfoDto snsUserInfoDto
    ) {
        if (sameUser.getKakaoId() == null) {
            System.out.println("중복");
            sameUser.setKakaoId(snsUserInfoDto.getId());
            sameUser.setNickname(snsUserInfoDto.getNickname());
            userRepository.save(sameUser);
        }
        return sameUser;
    }

    private String forceLogin(User kakaoUser) {
        UserDetailsImpl userDetails = UserDetailsImpl.builder()
                .username(kakaoUser.getUsername())
                .password(kakaoUser.getPassword())
                .build();
        Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);

        return JwtTokenUtils.generateJwtToken(userDetails);
    }
}

 

보기에 너무 길어서 접어놨다. 이제 이 코드가 왜 이렇게 구성되어 있는지 일부분씩 뜯어보며 알아보자.

 

1) 인가 코드로 액세스 코드 요청

private String getAccessToken(
            String code
    ) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP Body 생성
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", clientId);
        body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
        body.add("code", code);


        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        return jsonNode.get("access_token").asText();
    }

 

① Body

Body는 왜 저렇게 구성되어 있는가? grant_type은 뭐고 client_id는 뭐고.. 뭐지 싶을 것이다. 분명 어떠한 규칙에 의해 정해진 것일텐데 왜 저렇게 되어 있을까. 하는 생각도 들 것이다. 이럴 때 필요한게 뭐냐면 바로 공식 문서다.

 

 

Kakao Developers

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

developers.kakao.com

 

위 링크에서 보면 내부 정의를 어떻게 해주어야 유효한 정보를 얻어올 수 있는지 명시해주고 있다.

위 이미지에서, Required가 O인 정보는 반드시 보내줘야 하는 정보들이다.

 

파라미터 목록을 보면 grant_type, client_id.. 방금전에 코드에서 봤던 body의 내용들이 전부 담겨있다. 이 양식에 맞춰 작성만 해주면 되는 것이다. API가 생각보다 친절하다.

code의 경우 콜백 주소로 요청이 들어올 때 붙어있던 code 파라미터의 값 그대로 넣어주면 된다.

// HTTP Body 생성
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
body.add("code", code);

 

 

② Request

Body에서 구성된 정보를 기반으로 Request를 보내야 한다. 백엔드에서 말이다. 근데 어디로 보내야 하지? 그것도 공식문서에 있다.

조금만 스크롤을 내리면 보이는 구간이다.

빨간 박스 쳐진 주소로 POST 방식으로 요청을 보내면 되는 것이다. 오케이 좋았어.

// HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

 

 

③ Response

요청한 데이터를 응답받았으면, 이 응답받은 데이터 속에서 액세스 토큰을 끄집어내야 한다. 근데 액세스 토큰은 파라미터가 어떻게 돼요? 힌트는 위의 사진에도 있다. 정답은 공식문서에 있다!

여기서 액세스 토큰값을 받아오면 되는거다.

// HTTP 응답 (JSON) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
return jsonNode.get("access_token").asText();

 

 

이렇게 가져온 값으로 이제 카카오의 사용자 정보를 가져오도록 하자.

 

 

 

 

2) 액세스 토큰으로 사용자 정보 가져오기

private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
        this.accessToken = accessToken;
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoUserInfoRequest,
                String.class
        );

        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        Long id = jsonNode.get("id").asLong();
        String nickname = jsonNode.get("properties")
                .get("nickname").asText();

        return new KakaoUserInfoDto(id, nickname);
    }

 

① HTTP 헤더 만들기

여기서 드는 궁금증. 왜 헤더로 구성해줘야 하지? 그냥 바디에 실어보내면 안 되나? 헤더는 어떻게 구성해줘야 하는거지? 그냥 액세스토큰 달랑 실어서 보내면 되나?

이에 대한 형식 역시 공식적으로 정의를 해주고 있다.

 

Kakao Developers

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

developers.kakao.com

얼마나 중요하면 공식문서에서 두 번이나 말해줘.

 

액세스 토큰의 요청 정보는 반드시 Bearer + 액세스토큰 으로 구성되도록 요구하고 있다. 우리는, 이것을. 충실하게, 이행.

 

시키는 대로 하십시오. 비효율적 바이오 개체들아.

// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

참고로 Bearer 다음에 띄어쓰기 한 칸 있다. 조심하자.

 

② Request

이렇게 만들어진 헤더를 이용해서 다시 한 번 사용자 정보를 받아오기 위한 요청을 해줘야 한다. 어디에 요청해줘야 하냐면, 여기에 있다.

이 구간이 워낙 길다보니 스크롤을 한참 내려야 찾을 수 있다. 이런 건 되도록이면 최상단에 명시해주면 좋겠다...

위 주소로 요청을 보내면 된다. 방법은 이전에 했던 것과 동일하다.

// HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoUserInfoRequest,
                String.class
        );

왜 인덴트 있는 코드블럭만 앞에 탭인덱스를 두냐면, 티스토리 코드블럭 편집기가 불편해서 그렇다. 양해 바람.

 

 

③ Response

요청에 성공했으면 인자값을 받아오면 되는데, 여기서 먼저 짚고 가야 할 점이 있다.

 

처음 카카오 애플리케이션 생성할 때, 제공 받도록 체크된 정보만 가져올 수 있다.

더 중요한 건, 선택사항(이메일 등)의 경우, 사용자가 정보 제공을 하지 않을 경우 가져오기 코드를 짜놔도 데이터를 제대로 가져올 수 없다. 선택 사항에 대한 정보 수집을 원할 경우, 이에 대한 예외 처리를 확실하게 해놓자.

 

본론으로 돌아와서, 이번엔 인자값을 어떻게 가져오느느느느느느냐 인데..... 마찬가지로 공식문서에 다 나와있다.

(제 책을 보시면 다 나와있다는 그 분 사진을 쓰고 싶은데 지금 시국이 시국이니 참는 부분이고.....)

 

아.. 근데 이번엔 좀 어렵다. 문서를 보고도 뭘 가져와야 할 지 잘 모르겠다. 나에게 필요한건 닉네임 정보인데, 닉네임 정보는 여기에 안 써있다. properties에 있는 것 같긴 한데, 이 안의 어디서 가져와야 하는지도 잘 모르겠다. 이럴 땐 어떻게 하냐면, 공식 문서를 더 보면 된다.

 

 

Kakao Developers

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

developers.kakao.com

위 데이터들을 properties.프로퍼티명 으로 사용 가능하다.

공식문서에 따르면 수집된 프로퍼티 정보는 properties.프로퍼티명 으로 이용 가능하다고 되어있다. 이 양식을 이용해 데이터를 가져오면 된다.

 

String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
Long id = jsonNode.get("id").asLong();
String nickname = jsonNode.get("properties")
                          .get("nickname").asText();

return new KakaoUserInfoDto(id, nickname);

 

여기까지 진행하고 나면, 3번, 4번 과정은 이제 매뉴얼하게 내용을 구성해나가면 된다. 회원가입 처리를 시키고 싶으면 User DB에 저장하면 될 것이고, 일회성 로그인으로 처리하고 싶으면 DB 거치지 않고 강제로 로그인 처리를 하면 될 것이고, 길은 많다. 나는 JWT 방식을 이용한 로그인 처리를 구현했다. 이제부턴 당신의 꿈을 펼치면 될 것이다.

 

 

자, 글을 쓰면서 나도 이해를 많이 했다. 이제 이 이해를 바탕으로 네이버 로그인하러 간다. 딱기다려라 ㅋㅋ

 

다음편 네이버 로그인 구현은 아래 링크를 참조하면 된다.

 

[SpringBoot/OAuth2] WebSecurity 없이 REST API 환경에서 OAuth2 인증 구현하기 - 2. 네이버 로그인

OAuth2 [SpringBoot/OAuth2] WebSecurity 없이 REST API 환경에서 OAuth2 인증 구현하기 - 1. 카카오 로그인 OAuth2 참고로 이건 내가 WebSecurity를 쓰기 싫어서 이러는게 아니라 쓸 줄을 몰라서 방법을 찾은 것..

dazbee.tistory.com

 

댓글