본문 바로가기
관리자

Project/Poppin

Google OAuth2.0 소셜 로그인 플로우 - React, Kotlin(Spring boot), Authorization code grant 방식

728x90
반응형

 

혼자 공부하면서 Authorization Code Grant 방식의 Google 소셜 로그인에 대해 정리하였다. 이런 방법 말고도 백엔드에서 모두 처리하는 방식 등 다양한 방식을 공부했다. 프론트에서 모두 처리하는 방식, 백에서 모두 처리하는 방식이 있으나, 여기서는 프론트와 협업하는 방식에 대해서 다룬다. 참고로 프론트에서 모두 처리하는 방식은 google에서 사용자 정보가 담긴 토큰을 프론트로 내려줄 때 URL에 담아서 보내주는 방식이라 보안상 좋지 않다고 한다.

 

 

1. Authorization Code Grant 방식 Flow

 

1차로 프론트에서 Google에 요청해서 1회성 Auth Code를 발급받는다. 프론트가 백에 이를 전달하면, 백에서도 Google에 요청해서 사용자 정보가 포함된 토큰을 전송받고, 추가적인 정보를 얻기 위해 Google에 다시 요청한다. 구글에서 사용자 정보를 얻게되면 내부 처리를 한 뒤, 다시 프론트엔드로 유저정보를 보내준다. 각 단계에 대해서 상세히 기록해둔다.

 

 

2. 단계별 구체적 내용

 

2-1. 프론트엔드

백엔드 개발자고, 프론트엔드를 학습용으로 간단하게만 다뤄봐서 잘 몰랐다. 테스트용이라 그냥 intelliJ와 chatGPT의 도움을 받아서 최소한의 코드만 작성했다.

 

App.js 파일이 어떤 URL로 접근했을 때 각 페이지로 전달해주는 Router이고, .js로 된 파일들은 각 페이지를 표시해주는 파일이다.

 

 

 

2-1-1. App.js(전체 구성)

 

기본 경로 '/'에 해당하면 WelcomPage.js를 표시해준다. 그 외 플로우의 시작점은 '/login' 부분이고 적혀있는 순서대로 플로우에 따라 진행된다.

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import GoogleLoginDirect from "./GoogleLoginDirect";
import WelcomePage from "./WelcomPage";
import SendCodeToBackend from "./SendCodeToBackend";

const App = () => {
  return (
      <div className='App'>
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<WelcomePage />}></Route>
            <Route path="/login" element={<GoogleLoginDirect />}></Route>
            <Route path="/auth/google/callback" element={<SendCodeToBackend />}></Route>
            <Route path="/welcome" element={<WelcomePage />}></Route>
          </Routes>
        </BrowserRouter>
      </div>
  );
};

export default App;

 

 

 

 

 

 

2-1-2. GoogleLoginDirect.js

 

구글 클라우드 플랫폼에 들어가서 API 및 서비스 항목에서 서비스를 하나 만들면 된다. 이에 관한 내용은 구글링하면 많이 나와있다. 여기서 redirect_url, client_id, client_secret 값이 앞으로의 로직에 사용된다.

 

 

각종 정보를 아래 코드처럼 넣어서 googleAuthUrl로  보내면 된다. 

 

const GoogleLoginDirect = () => {
    const googleClientId = {구글 클라우드 플랫폼에서 발급한 Id};
    const googleRedirectUrl = 'http://localhost:3000/auth/google/callback'; //google로부터 redirect 당할 url
    const googleScope = 'https://www.googleapis.com/auth/drive.metadata.readonly';

    const params = new URLSearchParams({
        scope: googleScope,
        include_granted_scopes: true,
        response_type: 'code',
        client_id: googleClientId,
        redirect_uri: googleRedirectUrl,
    });

    const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;

    const loginHandler = () => {
        window.location.href = googleAuthUrl;
    };

    return (
        <div onClick={() => loginHandler()}>
            <h2>Sign in with Google</h2>
        </div>
    );
};

export default GoogleLoginDirect;

 

 

 

구글용 각종 인증 URL : 이 주소를 찾기가 힘들었다. 공식 문서도 영어를 번역한 것이라 어색해서 읽기가 쉽지 않았다. 현재로서는 아래 코드의 googleAuthUrl로 접속하면 되지만, 나중에는 version이 변경될 수도 있다. 이런 인증에 필요한 각종 URL은 아래 문서

https://developers.google.com/identity/openid-connect/openid-connect?hl=ko

 

의 맨 마지막 부분, Discovery 문서에 표시된 URL로 요청을 보내서 확인할 수 있다.

 

포스트맨으로 그냥 GET으로 보내보면, 바로 뜬다! 다시 말해 구글에서 인증용 URL 버전이나 기능을 자꾸 바꾸니까, 동적으로 참조해라는 뜻인 것 같다.

 

 

페이지의 구성은 허접하다. 버튼을 만드는 것도 바로 떠오르지 않아서 그냥 텍스트를 클릭하면 loginHandler()에 의해서 url로 요청이 가는 방식으로 만들었다.

 

 

 

구글 사용자 로그인 화면이 보이고, 계정 선택을 통해 로그인을 하면 아래 url와 유사한 방식으로 응답이 오면서 위  GoogleLoginDirect.js 파일에서 구글에 건네준 GoogleRedirectUrl (const googleRedirectUrl = 'http://localhost:3000/auth/google/callback'; ) 로 리다이렉트 된다.

 

http://localhost:3000/auth/google/callback?code=4%2F(중략)

kw&scope=email+profile+https%3A%2F%2Fhttp://www.googleapis.com%2Fauth%2Fdrive.metadata.readonly+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&authuser=0&prompt=consent

 

구글이 localhost:3000/auth/google/callback으로 Code를 쿼리 스트링에 담아서 보내준다.

 

여기서 code값은 URL Encoded된 방식으로 온다. (4%2F => 4/)

  • 4/...으로 와야 정상이다. 다른 방식으로 온다면 (2023년 11월 19일 대비) 구글의 스펙이 바뀌었거나, 뭔가 잘못된 주소나 인자를 갖고 요청을 보냈을 확률이 있으니 점검해야할 수도 있다.
  • 인코딩된 방식이니, console.log 등을 통해 개발자 도구의 Console 탭에서 4%2F식으로 인코딩 되어 표시되는지, 4/로 Decoding 되어 표시되는지 등을 잘 디버깅해봐야한다. 구글링해보면 뭔가 인코딩 때문에 문제가 발생한 경우가 많았다.

 

 

 

 

 

2-1-3. SendCodeToBackend.js

 

<Route path="/auth/google/callback" element={<SendCodeToBackend />}></Route>

 

이제 해당 주소로 요청이 올테니, SendCodeToBackend 페이지로 이동해서 로직이 진행될 것이다.

 

useLocation, useNavigate가 뭘 뜻하는지까지 공부하지 않았다. 그냥 GPT가 짜주는 코드로 썼다.

 

다만, 주소에서 URLSearchParams를 통해 'code'값을 얻어오고, axios.post를 통해 백엔드로 이 값을 전달한 뒤, .then을 통해서 navigate(/welcome)으로 /welcome URL로 이동한다는 플로우는 인지하고 있어야한다.

 

  • googleLoginAuthDtoRequest는 백엔드에서 body로 받는 이름이다. 그냥 일치시켜줘서 초보도 알기 쉽게 했다. 그리고 이 데이터 객체를 code, redirect_url로 구성해서 code값은 백엔드에 전달하고 redirect_url로 백엔드 처리 후 다시 이 페이지로 오게해서 .then이 진행되도록 했다.
import React, { useEffect } from 'react';
import axios from 'axios';
import {useLocation, useNavigate} from 'react-router-dom';

const SendCodeToBackend = () => {
    const location = useLocation();
    const navigate = useNavigate();

    useEffect(() => {
        const code = new URLSearchParams(location.search).get("code");

        if (code) {
            const googleLoginAuthDtoRequest = {
                code: code,
                redirect_url: "http://localhost:3000/auth/google/callback"
            };

            axios.post('http://localhost:8080/oauth/google', googleLoginAuthDtoRequest, {
                headers: {
                    'Content-Type': 'application/json'
                }
            })
                .then(response => {
                    console.log("this is response!!!!!!!!!!!!!");
                    console.log(response.data);
                    navigate('/welcome');
                })
                .catch(error => {
                    console.error(error);
                });
        }
    }, [location.search]);

    return (
        <div>
            <h1>Redirecting...</h1>
        </div>
    );
};

export default SendCodeToBackend;

 

 

아래 영상에서 중간에 Redirecting... 이 보이는 부분이 해당 페이지이다. 이후 백엔드에서 처리되고 응답이 넘어와서 Welcom to the App!으로 넘어갔다. 이제 백엔드에서 어떻게 처리하는지 알아볼 차례다.

 

 

 

2-2.  백엔드

 

2-2-1. 백엔드 프로젝트 구성하기

앞서 살펴본대로 SpringBoot, Kotlin, mysql을 활용하여 백엔드 프로젝트를 구성한다. intelliJ, gradle을 사용하여 디펜던시 관리 및 빌드를 한다. dependency 및 자바 설정을 build.gradle.kts에 다음과 같이 작성해주었다.(필수적이지 않은 dependency 까지 막 있음..)

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "3.1.5"
    id("com.palantir.docker-compose") version "0.33.0"
    kotlin("jvm") version "1.8.0"
    application
}

group = "com.side176"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(kotlin("test"))
    implementation("org.springframework.boot:spring-boot-starter:3.1.5")
    implementation("org.springframework.boot:spring-boot-starter-web:3.1.5")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.1.5")
    implementation("org.springframework.boot:spring-boot-starter-logging:3.1.5")
    runtimeOnly("org.jetbrains.kotlin:kotlin-reflect:1.9.10")
    compileOnly("org.projectlombok:lombok:1.18.26")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10")

    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2")
    implementation("com.google.code.gson:gson:2.8.9")

    implementation("org.springframework.boot:spring-boot-starter-security:3.1.5")
    implementation("org.springframework.security:spring-security-oauth2-client:6.1.2")
    implementation("org.springframework.security:spring-security-oauth2-jose:6.1.2")
    implementation("org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.1.0.RELEASE")

    implementation("io.jsonwebtoken:jjwt-api:0.11.5")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")

    implementation("org.springframework.boot:spring-boot-starter-thymeleaf:3.1.2")
    implementation("com.mysql:mysql-connector-j:8.1.0")
    implementation("org.springframework.boot:spring-boot-devtools:3.1.2")
    testImplementation("org.springframework.boot:spring-boot-starter-test:3.1.5")
    testImplementation("org.springframework.security:spring-security-test:6.1.2")
    testImplementation("com.h2database:h2:2.2.224")

}

tasks.test {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString()
}

 

 

application.yml 파일에 mysql 연결용 설정 내용들을 작성하고, DB 연결 후 User Entity를 작성했다. 이 내용은 너무 지엽적인 이야기이고, OAuth 로그인 플로우를 기록하는 내용은 아니라서 넘어간다.

 

 

2-2-2. OAuthController

 

localhost:8080/oauth/google로 프론트엔드에서 요청을 보냈을 때 처리하는 Controller이다. GoogleLoginAuthDtoRequest로 상기 다뤘던 code, redirect_url을 받아온다. Service의 각 메서드별 순서대로 구글에서 AccessToken을 받아오고, UserProfile 정보를 불러오고, UserProfile 정보를 DB에 저장한다. 마지막에는 내부적으로 활용할 수 있도록 AccessToken, RefreshToken을 생성하고 이 정보 및 UserProfile 정보들을 ResponseEntity로 프론트에 리턴해주는 것이 맞겠으나, 일단 그 부분은 제외하였다.

@RestController
@RequestMapping("/oauth")
class OAuthController(
    private val oAuth2UserAuthCodeServiceImpl: OAuth2UserAuthCodeServiceImpl
) {
    // user auth code 로그인 처리
    @PostMapping("/google")
    fun authCodeLoginGoogle(@RequestBody googleLoginAuthDtoRequest: GoogleLoginAuthDto.Companion.Request): String {
        val googleLoginAuthDto = GoogleLoginAuthDto.fromRequest(googleLoginAuthDtoRequest)
        val googleTokenResponse = oAuth2UserAuthCodeServiceImpl.getGoogleAccessToken(googleLoginAuthDto)
        val googleUserProfile = oAuth2UserAuthCodeServiceImpl.getGoogleUserProfile(googleTokenResponse.access_token)
        oAuth2UserAuthCodeServiceImpl.saveOAuthUserProfileAndUser(googleUserProfile)

        return "haha"
    }

}

 

 

2-2-3. getGoogleAccessToken 메서드

 

@Value 어노테이션으로 설정한 application.yml에 있는 client-id 및 client-secret 값을 기반으로 구글에 요청을 보낸다. 받은 Response를 ObjectMapper을 활용해서 Deserialize 처리하고, 객체로 받는다.

@Service
@RequiredArgsConstructor
class OAuth2UserAuthCodeServiceImpl(private val userRepository: UserRepository) {
    /*
        front-end의 authCode를 전송받아 provider 서버와 통신하여 사용자 정보를 얻어오는 방식
     */

    @Value("\${spring.security.oauth2.client.registration.google.client-id}") lateinit var googleClientId: String
    @Value("\${spring.security.oauth2.client.registration.google.client-secret}") lateinit var googleClientSecret: String

    fun getGoogleAccessToken(googleLoginAuthDto: GoogleLoginAuthDto): GoogleTokenDto.Companion.Response {
        val requestUrl = "https://oauth2.googleapis.com/token" //todo 동적 참조

        val restTemplate = RestTemplate()

        val headers = HttpHeaders()
        headers.set("Content-Type", "application/x-www-form-urlencoded")

        val params: MultiValueMap<String, String> = LinkedMultiValueMap()
        val code = googleLoginAuthDto.code
        val redirectUri = googleLoginAuthDto.redirect_url

        params.add("code", code)
        params.add("client_id", googleClientId)
        params.add("client_secret", googleClientSecret)
        params.add("redirect_uri", redirectUri)
        params.add("grant_type", "authorization_code")
        params.add("scope", "email profile openid")

        println("Request URL: $requestUrl")
        println("Request Headers: $headers")
        println("Request Parameters: $params")

        val responseEntity = restTemplate.postForEntity(requestUrl, HttpEntity(params, headers), String::class.java)
        println("Response: ${responseEntity.body}")

        if (responseEntity.statusCode == HttpStatus.OK) {
            return responseEntity.body.let{
                ObjectMapper().readValue(it, GoogleTokenDto.Companion.Response::class.java)
            }
        } else {
            throw GoogleAccessTokenException("Failed to get Google access token")
        }
    }

 

GoogleTokenDto

현재 기준, 구글에서 던져주는 응답을 매핑한 것을 Dto로 처리했다. 여기서 access_token 값을 다음 메서드로 넘겨줄 것이다.

data class GoogleTokenDto(
    val access_token: String = "",
    val expires_in: Int = 0,
    val scope: String = "",
    val token_type: String = "",
    val id_token: String = "",
    val refresh_token: String = ""
) {
    companion object {
        data class Response(
            val access_token: String = "",
            val expires_in: Int = 0,
            val scope: String = "",
            val token_type: String = "",
            val id_token: String = "",
            val refresh_token: String = ""
        )

        fun fromResponse(response: Response): GoogleTokenDto {
            return GoogleTokenDto(
                response.access_token,
                response.expires_in,
                response.scope,
                response.token_type,
                response.id_token,
                response.refresh_token
            )
        }
    }
}

 

 

 

2-2-4. getGoogleUserProfile

 

앞서 받은 accessToken을 바탕으로 google에 사용자 정보를 요청한다.

fun getGoogleUserProfile(accessToken: String?): GoogleUserProfileDto {
    val requestUrl = "https://openidconnect.googleapis.com/v1/userinfo?access_token=$accessToken" //TODO openID search에서 주소 dynamic 처리
    val restTemplate = RestTemplate()

    val responseEntity = restTemplate.getForEntity(requestUrl, String::class.java)
    println("Response: ${responseEntity.body}")

    if (responseEntity.statusCode == HttpStatus.OK) {
        val googleUserInfoResponseDto = responseEntity.body.let {
            ObjectMapper().readValue(it, GoogleUserProfileDto::class.java)
        }
        return googleUserInfoResponseDto
    } else {
        throw GoogleUserInfoException("Failed to get Google user info")
    }
}

 

 

GoogleUserProfileDto

현재 기준, 기본 scope 기준으로 구글에서 응답해주는 정보를 Dto 클래스로 정의한 부분이다. 여기서 sub 부분은 구글에서 알려주는 각 사용자의 id 값(ex. 1058127019212)이다.

data class GoogleUserProfileDto(
    val sub: String = "",
    val name: String = "",
    val given_name: String = "",
    val family_name: String = "",
    val picture: String = "",
    val email: String ="",
    val email_verified: Boolean = false,
    val locale: String ="",
) {
    companion object {
        class Response(
            val sub: String = "",
            val name: String = "",
            val given_name: String = "",
            val family_name: String = "",
            val picture: String = "",
            val email: String ="",
            val email_verified: Boolean = false,
            val locale: String ="",
        )

 

DB에 이런식으로 저장하는 로직을 추가했다.

 

 

 

해당 로직 완료 후, controller에서 응답이 return 되면 프론트에서 알려준 redirect_url로 리다이렉트를 한다. 그럼 위에서 살펴본대로 Welcome페이지가 뜨면서 소셜 로그인이 완료된다!

728x90
반응형