Security 없이 카카오 로그인 구현하기(webflux)

henu·2024년 6월 9일

1)개발환경


개발환경버전
Java17
Spring Boot3.2.3
Spring Boot Starter WebFlux2.7.5
Spring Boot Starter Tymeleaf2.7.5

2)환경 구성 : Kakao 기본 환경 설정


1.카카오 로그인 과정


2.Kakao developers 내에 애플리케이션 추가


3.카카오 로그인 활성화



4.플랫폼 등록


💡 '앱 설정 탭 > 플랫폼 > Web > Web 플랫폼 등록'

5.Redirect URI 지정


💡 제품 설명 > 카카오 로그인 > Redirect URI

6.Client Secret 지정


💡 '제품 설명 > 카카오 로그인 > 보안 > 코드 생성'

💡 최초 활성화가 안되어 있으므로 활성화시켜준다.

7.카카오톡 로그인을 통해서 가져올 정보 선택


💡 '제품 설명 > 카카오 로그인 > 동의항목' 탭을 선택하고 가져올 범위에 대해서 선택

8.카카오 로그인 테스트


💡 REST_API_KEY는 ‘앱 설정’ > ‘앱 키’ > ‘REST API 키’를 복사해서 붙여 넣으면 되고, REDIRECT_URI의 경우는 방금 전에 추가한 리다이렉션 URI를 넣는다.

https://kauth.kakao.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code

9.리다이렉트 페이지 확인


3) 구현


💡 아래와 같은 순서도 기반으로 프로세스를 구성

💡 해당 부분에서는 아래의 과정을 통해 최종적으로 카카오톡 로그인을 통해 사용자 정보를 조회하는 과정으로 구성
1. 사용자가 ‘카카오 로그인’ 버튼을 클릭합니다.
2. 카카오 로그인 화면이 출력됩니다.
3. 로그인 화면에서 ‘동의하고 계속하기’ 버튼 클릭합니다.
4. 인증 이후 ‘인가 코드’를 발급받습니다. 리소스 서버 요청 (https://kauth.kakao.com/oauth/authorize)
5. 리다이렉션 URI로 이동됩니다.
6. ‘인가코드’로 ‘접근 토큰(Access Token)’ 발급받습니다. 리소스 서버 요청(https://kauth.kakao.com/oauth/token)
7. ‘접근 토큰’으로 로그인된 ‘사용자 정보를 조회’ 합니다. 리소스 서버 요청(https://kapi.kakao.com/v2/user/me)

1.의존성 추가


dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'             // Thymeleaf Template
    implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.1.0'             // Thymeleaf Layout
    implementation 'org.springframework.boot:spring-boot-starter-webflux:2.7.13'        // Spring Webflux
}

2.주요 사용 API 문서


분류HTTP MethodURL필수 파라미터설명
인가코드 발급GEThttps://kauth.kakao.com/oauth/authorizeclient_id
redirect_uri
response_type
카카오 로그인 동의 화면을 호출하고, 사용자 동의를 거쳐 인가 코드를 발급
토큰 발급POSThttps://kauth.kakao.com/oauth/tokengrant_type
client_id
redirect_uri
code
이전에 발급 받은 인가코드를 기반으로 토큰을 발급
사용자 정보 조회GET/POSThttps://kapi.kakao.com/v2/user/meAuthorization
Content-type
현재 로그인한 사용자의 정보를 불러온다.

3.UI 화면 및 인가코드 받기


💡 간단한 UI를 구성하고 버튼을 눌렀을 때 인가코드를 받도록 구성

3.1. 간단한 로그인 페이지와 로그인 페이지를 불러오는 Controller를 구성
<!DOCTYPE html>
<!-- Default Layout Import-->
<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{common/layouts/defaultLayout}"
      layout:fragment="Content"
>
<head>
    <title></title>
    <!-- IN-line JS 영역-->
    <script th:inline="javascript" type="text/javascript">

    </script>

</head>
<body>
<div>
    <div>로그인</div>
    <a href="https://kauth.kakao.com/oauth/authorize?client_id=KEY&redirect_uri=http://localhost:8000/api/v1/oauth2/kakao&response_type=code">카카오톡 로그인</a>
</div>
</div>

</body>
</html>

💡 로그인 페이지를 불러오는 @Controller

/**
 * 로그인 페이지
 */
@GetMapping("/login")
public String login(Model model) {
    model.addAttribute("title", "");
    return "pages/login/loginPage";
}
3.2 카카오에서 지정한 리다이렉트 API를 구성

💡 해당 API는 카카오톡 로그인을 하였을 때 리다이렉트 되는 API입니다. 간단하게 파라미터로 code라는 반환값을 받아서 ‘Access Code’를 확인하도록 구성

/**
 * 카카오 로그인 redirect uri
 *
 * @return ResponseEntity<ApiResponse>
 * @link
 * @since 2023.07.15
 */
@GetMapping("/kakao")
public ResponseEntity<ApiResponse<Object>> kakaoLogin(@RequestParam String code) {
    String answer = "";
    log.debug("[+| Kakao Login AccessToken :: " + code);
    ApiResponse<Object> ar = ApiResponse
            .builder()
            .result(answer)
            .resultCode(SUCCESS_CODE)
            .resultMsg(SUCCESS_MSG).build();
    return new ResponseEntity<>(ar, HttpStatus.OK);
}

로그인 페이지

로그인 성공시 페이지

4.토큰 및 사용자 정보 조회


💡 인가 코드로 ‘토큰’을 생성하고 해당 토큰을 통해서 사용자 정보를 조회하는 과정입니다.
1. RequestParam의 code 형태로 인가 코드를 반환받습니다.
2. 인가 코드를 통해서 토큰을 생성합니다.
3. 생성된 토큰에는 접근 코드(Access Code)가 있는데 이를 통해서 Header에 이를 추가하여 사용자 정보를 조회합니다.
4. 최종적으로 사용자 조회를 확인합니다.

4.1. RequestParam의 code 형태로 인가 코드를 반환받습니다.

💡 해당 API는 카카오톡 로그인을 하였을 때 리다이렉트 되는 API이다.
💡 간단하게 파라미터로 code라는 반환값을 받아서 ‘Access Code’를 확인하도록 구성

/**
 * 카카오 로그인 redirect uri
 */
@GetMapping("/kakao")
public ResponseEntity<ApiResponse<Object>> kakaoLogin(@RequestParam String code) {
    String answer = "";
    log.debug("[+| Kakao Login AccessToken :: " + code);
    ApiResponse<Object> ar = ApiResponse
            .builder()
            .result(answer)
            .resultCode(SUCCESS_CODE)
            .resultMsg(SUCCESS_MSG).build();
    return new ResponseEntity<>(ar, HttpStatus.OK);
}
4.2 '인가코드'를 기반으로 토큰을 생성

💡 토큰을 발급받기 위해 필수적으로 들어가는 요소를 구성하여 WebClient를 이용하여 요청

package com.example.backend.controller;

import com.adjh.multiflexapi.common.codes.SuccessCode;
import com.adjh.multiflexapi.common.codes.constans.MainConstants;
import com.adjh.multiflexapi.common.response.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.json.simple.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;

/**
 * Spring Boot OAuth 2 Client 구성
 */
@Slf4j
@RestController
public class OAuth2Controller {

    private static final Logger log = LoggerFactory.getLogger(LoginController.class);
    private static final String SUCCESS_CODE = "200";
    private static final String SUCCESS_MSG = "로그인 성공";

    private final String GRANT_TYPE = "authorization_code";

    @Value("${kakao.client_secret}")
    private String clientSecret;
    @Value("${kakao.client_id}")
    private String kakaoClientId;

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

	/**
     * 로그인 페이지
     */
    @GetMapping("/login")
    public String login(Model model) {
        model.addAttribute("title", "");
        model.addAttribute("kakaoClientId", kakaoClientId);
        model.addAttribute("kakaoRedirectUri", kakaoRedirectUri);
        return "pages/login/loginPage";
    }
    
    /**
     * 카카오 로그인을 동의 하였을때 처리 이후 redirect 되는 API
     */
    @GetMapping("/ouath2/kakao")
    public ResponseEntity<ApiResponse<Object>> kakaoLogin(@RequestParam String code) {
        // [STEP1] 인가 코드를 받습니다.
        log.debug("[+| Kakao Login AccessToken :: " + code);

        // [STEP2] 인가 코드를 기반으로 토큰을 발급
        JSONObject tokenInfo = generateToken(code);
        String accessToken = String.valueOf(tokenInfo.get("access_token"));
        int refreshTokenExpiresIn = (int) tokenInfo.get("refresh_token_expires_in");
        String refreshToken = String.valueOf(tokenInfo.get("refresh_token"));
        String scope = String.valueOf(tokenInfo.get("scope"));


        ApiResponse<Object> ar = ApiResponse
                .builder()
                .result("")
                .resultCode(SUCCESS_CODE)
                .resultMsg(SUCCESS_MSG).build();
        return new ResponseEntity<>(ar, HttpStatus.OK);
    }


    /**
     * 인가 코드를 통해서 토큰 발급
     */
    private JSONObject generateToken(String code) {
        // [STEP2] 전달 데이터 구성
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("client_id", CLIENT_ID);
        formData.add("grant_type", GRANT_TYPE);
        formData.add("client_secret", CLIENT_SECRET);
        formData.add("redirect_uri", "http://localhost:8000/api/v1/oauth2/kakao");

        // [STEP2] WebClient 이용하여 토큰 발급
        return WebClient.builder()
                .baseUrl("https://kauth.kakao.com/oauth/token")
                .build()
                .post()
                .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
                .body(BodyInserters.fromFormData(formData))
                .retrieve()
                .bodyToMono(JSONObject.class)
                .block();
    }
}
    
4.3 '접큰 코드'를 통해서 사용자 정보 조회

💡 이전에 반환받은 접근 코드(Access Code)를 통해서 사용자 정보 조회

@Slf4j
@Controller
public class LoginController {

    private static final Logger log = LoggerFactory.getLogger(LoginController.class);
    private static final int SUCCESS_CODE = 200;
    private static final String SUCCESS_MSG = "로그인 성공";

    private final String GRANT_TYPE = "authorization_code";

    @Value("${kakao.client_secret}")
    private String clientSecret;
    @Value("${kakao.client_id}")
    private String kakaoClientId;

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

    /**
     * 로그인 페이지
     */
    @GetMapping("/login")
    public String login(Model model) {
        model.addAttribute("title", "");
        model.addAttribute("kakaoClientId", kakaoClientId);
        model.addAttribute("kakaoRedirectUri", kakaoRedirectUri);
        return "pages/login/loginPage";
    }

    /**
     * 카카오 로그인을 동의 하였을때 처리 이후 redirect 되는 API
     */
    @GetMapping("/oauth2/kakao")
    public ResponseEntity<ApiResponse<Object>> kakaoLogin(@RequestParam String code) {
        System.out.println("0");
        // [STEP1] 인가 코드를 받습니다.
        log.debug("[+| Kakao Login AccessToken :: " + code);


        // [STEP2] 인가 코드를 기반으로 토큰을 발급
        JSONObject tokenInfo = generateToken(code);
        String accessToken = String.valueOf(tokenInfo.get("access_token"));
        int refreshTokenExpiresIn = (int) tokenInfo.get("refresh_token_expires_in");
        String refreshToken = String.valueOf(tokenInfo.get("refresh_token"));
        String scope = String.valueOf(tokenInfo.get("scope"));

        System.out.println("1");
        // [STEP3] 접근 코드를 기반으로 사용자 정보를 조회
        JSONObject userInfo = findUserInfo(accessToken);
        String kakaoAccount = String.valueOf(userInfo.get("kakao_account"));
        System.out.println("kakaoAccount :: " + kakaoAccount);

        System.out.println("2");
        ApiResponse<Object> ar = ApiResponse
                .builder()
                .result("")
                .resultCode(SUCCESS_CODE)
                .resultMsg(SUCCESS_MSG).build();
        return new ResponseEntity<>(ar, HttpStatus.OK);
    }


    /**
     * 인가 코드를 통해서 토큰 발급
     */
    private JSONObject generateToken(String code) {
        // [STEP2] 전달 데이터 구성
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("client_id", kakaoClientId);
        formData.add("grant_type", GRANT_TYPE);
        formData.add("client_secret", clientSecret);
        formData.add("redirect_uri", "http://localhost:8000/api/v1/oauth2/kakao");

        // [STEP2] WebClient 이용하여 토큰 발급
        return WebClient.builder()
                .baseUrl("https://kauth.kakao.com/oauth/token")
                .build()
                .post()
                .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
                .body(BodyInserters.fromFormData(formData))
                .retrieve()
                .bodyToMono(JSONObject.class)
                .block();
    }

    /**
     * 사용자 정보 조회
     */
    private JSONObject findUserInfo(String accessToken) {

        // 전달 받은 토큰을 기반으로 사용자 정보 조회
        return WebClient.builder()
                .baseUrl("https://kapi.kakao.com/v2/user/me")
                .build()
                .post()
                .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
                .header("Authorization", "Bearer " + accessToken)
                .retrieve()
                .bodyToMono(JSONObject.class)
                .block();
    }
}
4.4 비동기적 처리

💡 리액티브 프로그래밍을 사용하여 블로킹 호출을 피하고 비동기적으로 작업처리한다. 리액티브 스트림은 데이터 흐름을 구성하고, 데이터가 준비되면 해당 작업을 처리한다. 이를 통해 높은 성능과 확장성을 유지할 수 있다.

package com.example.backend.controller;

import com.example.backend.dto.ApiResponse;
import groovy.util.logging.Slf4j;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@Slf4j
@Controller
public class LoginController {

    private static final Logger log = LoggerFactory.getLogger(LoginController.class);
    private static final int SUCCESS_CODE = 200;
    private static final String SUCCESS_MSG = "로그인 성공";

    private final String GRANT_TYPE = "authorization_code";

    @Value("${kakao.client_secret}")
    private String clientSecret;
    @Value("${kakao.client_id}")
    private String kakaoClientId;

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

    @GetMapping("/login")
    public String login(Model model) {
        model.addAttribute("title", "");
        model.addAttribute("kakaoClientId", kakaoClientId);
        model.addAttribute("kakaoRedirectUri", kakaoRedirectUri);
        return "pages/login/loginPage";
    }

    @GetMapping("/oauth2/kakao")
    public Mono<ResponseEntity<ApiResponse<Object>>> kakaoLogin(@RequestParam String code) {
        log.debug("[+| Kakao Login AccessToken :: " + code);

        return generateToken(code)
                .flatMap(tokenInfo -> {
                    String accessToken = tokenInfo.getString("access_token");
                    int refreshTokenExpiresIn = tokenInfo.getInt("refresh_token_expires_in");
                    String refreshToken = tokenInfo.getString("refresh_token");
                    String scope = tokenInfo.getString("scope");

                    return findUserInfo(accessToken)
                            .map(userInfo -> {
                                String kakaoAccount = userInfo.getString("kakao_account");
                                System.out.println("kakaoAccount :: " + kakaoAccount);

                                ApiResponse<Object> ar = ApiResponse
                                        .builder()
                                        .result("")
                                        .resultCode(SUCCESS_CODE)
                                        .resultMsg(SUCCESS_MSG).build();
                                return new ResponseEntity<>(ar, HttpStatus.OK);
                            });
                });
    }

    private Mono<JSONObject> generateToken(String code) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("client_id", kakaoClientId);
        formData.add("grant_type", GRANT_TYPE);
        formData.add("client_secret", clientSecret);
        formData.add("redirect_uri", "http://localhost:8000/oauth2/kakao");

        return WebClient.builder()
                .baseUrl("https://kauth.kakao.com/oauth/token")
                .build()
                .post()
                .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
                .body(BodyInserters.fromFormData(formData))
                .retrieve()
                .bodyToMono(JSONObject.class);
    }

    private Mono<JSONObject> findUserInfo(String accessToken) {
        return WebClient.builder()
                .baseUrl("https://kapi.kakao.com/v2/user/me")
                .build()
                .post()
                .header("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
                .header("Authorization", "Bearer " + accessToken)
                .retrieve()
                .bodyToMono(JSONObject.class);
    }
}
profile
주니어 백엔드 개발자입니다

0개의 댓글