WEB

[OAuth] [JWT] [Spring] 구글 소셜 로그인 Google Login

데일리코딩 2024. 3. 1. 13:24

 

구글 소셜로그인 Intro

많은 서비스들이 사용자들이 쉽게 접근하여 서비스 이용까지 불편함을 겪지 않기 위해 소셜 로그인을 적극 활용한다.

내가 주로 사용하는 트렐로, 인프런 등등 웹 서비스 하는 사이트들은 간편한 로그인 서비스 기능을 제공한다.

 

우리 서비스도 사용자들의 편의성을 고려하여 도입하기로 하였고 이번 기회에 직접 구현하면서 많은 것들을 배웠다.

 

우선 해당 포스팅을 통해 로그인 기능을 구현하고자 하는분들이 이해하고 따라 만들기 위해 선수 지식들이 필요하다.

구체적인 코드와 내가 만난 여러 에러들을 공유하고 후에 해당 기능을 구현할때 큰 어려움이 없었으면 한다.

 

클라이언트 단은 react 를 사용하였고 패키지 관리는 yarn 으로 했다.

서버는 spring-boot 를 사용하였다.  라이브러리 관리는 maven 사용했다.

필요한 선수지식

1. spring mvc 와 spring 의 인터셉터 개념

2. jwt (json web token)

3. javascript 의 axios 를 사용하여 클라이언트와 서버와 통신 (RESTful API )

필요한 라이브러리

정말 필수적인 라이브러리 이므로 꼭 설치하고 사용법을 익혀두자.

React

1. axios (서버와 서로 통신하기 위해 필수적이다 자바스크립트에서 기본적으로 제공하는 fetch 함수를 사용해도 무관하다.)

https://axios-http.com/kr/docs/intro

 

시작하기 | Axios Docs

시작하기 브라우저와 node.js에서 사용할 수 있는 Promise 기반 HTTP 클라이언트 라이브러리 Axios란? Axios는 node.js와 브라우저를 위한 Promise 기반 HTTP 클라이언트 입니다. 그것은 동형 입니다(동일한 코

axios-http.com

2. react-router-dom (react 에서 라우팅을 위해 필수 라이브러리이다. next.js 에서는 기본적으로 제공하지만.. )

https://reactrouter.com/en/main/start/tutorial

 

Tutorial v6.22.1 | React Router

Tutorial Welcome to the tutorial! We'll be building a small, but feature-rich app that lets you keep track of your contacts. We expect it to take between 30-60m if you're following along. 👉 Every time you see this it means you need to do something in th

reactrouter.com

 

Spring

1. FeignClient 라이브러리를 사용했다 기존 RestTemplate를 사용해도 좋지만 훨씬 간결하고 유지보수성 코드를 사용했다
RestTemplate에서 FeignClient 도입한 포스팅을 통해 FeignClient 사용법과 왜 사용하면 좋은지 참고정도만 하면 좋겠다.

https://junghan49.tistory.com/7

 

[JAVA Spring ] 카카오 로그인 기능 FeignClient 도입기

spring 서버에서 카카오 서버에게 리소스에 접근권한을 가지는 토큰을 요청할때 기존의 RestTemplate를 사용해왔는데 다른 개발자들의 FeignClient를 사용하여 소셜 로그인을 구현한 코드를 보았는데

junghan49.tistory.com

2. json web token 해당 라이브러리는 json 형태의 데이터를 토큰 형식으로 암호화/복호화 기능을 제공하는 라이브러리이다.

이번 OAuth의 가장 핵심적인 라이브러리이다 

https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>

 

나는 maven에 json 관련 라이브러리 설정했다. 

 

OAuth의 전체적인 흐름

우선 전체적인 자세한 흐름을 살펴보기 전에 그림으로 한번 살펴보는것이 좋겠다.

https://data-jj.tistory.com/53 &nbsp;이미지 출처

우리는 구글 소셜 로그인인데 왜 카카오냐고 묻는다면 카카오, 네이버, 구글 뭐든 소셜 로그인의 전체적은 flow는 다 똑같다 설정만 조금 다를뿐이지 실제 코드도 거의 비슷하다. 따라서 카카오, 네이버, 구글 이중 하나의 소셜로그인 기능만 구현한다면 다 할 수있다.

부가 설명 : 

1. 로그인 / 동의화면은 보통 api를 제공하는 플랫폼에서 해당 화면을 제공한다. 우리가 해야하는 작업은 react 에서 로그인 버튼을 클릭하면 해당 링크로 이동하게 해주면 되는것뿐이다.

 

2. 플랫폼이 제공하는 로그인화면에서 성공적으로 로그인을 마쳤다면 인가코드를 줄 것이다 code 라는 파라미터를 통해서 접근 토큰을 얻을 수 있는 긴 이상하게 생긴 문자열을 준다.

 

3. 2번에서 인가코드를 줄때 파라미터를 통해서 준다고 말했다. 그렇다 해당 플랫폼에서 redirect_url 을 통해서 우리가 설정한 url 에 code란 파라미터를 통해서 코드값을 주는것인데 이때 해당 redirect_url 로 이동한 화면단에서는 서버에게 이 값을 전달하는 것이다.

 

4. react 에서 받은 code값을 서버는 해당 값을 가지고 직접 플랫폼에게 리소스에 접근할 수있는 토큰을 요청한다.

 

5. 플랫폼에서는 관리자 화면에서 등록한 redirect_url이 일치하는지 확인하고 해당 토큰을 서버에게 반환해준다.

 

6. 접근 토큰을 받은 서버는 다시 플랫폼에게 토큰을 통해 사용자의 정보를 요청한다.

 

7. 서버는 사용자의 정보를 바탕으로 jwt 토큰을 생성하고 클라이언트에게 토큰을 반환한다 ** (절대 중요한것은 절대로 절대로 플랫폼에서 받은 접근 토큰은 클라이언트에게 반환되거나 노출되면 절대 안된다. )

 

8. 서버에서 jwt 토큰을 받은 클라이언트는 이제 서비스를 이용할 수 있게 해당 페이지로 이동시켜주면 되는것이다.

 

구글 소셜 로그인에 필요한 사전 작업

해당 블로그 포스트에 잘 정리되어있으므로 여기서 소셜 로그인에 필요한 사전 작업을 하면 된다.

https://notspoon.tistory.com/45

 

구글로그인 쉽게 구현하기 1편 - Google Developers 설정

구글 로그인 API 클라이언트 입장에서 수많은 사이트의 모든 아이디 비밀번호를 기억하기는 쉽지 않다. 또한 서비스를 제공해주는 리소스 오너 또한 안전하게 보관하여야 하기 때문에 부담된다.

notspoon.tistory.com

 

구글 콘솔에서 설정하는 url 은 다음과 같다

https://console.cloud.google.com/welcome?project=forward-glass-413404

 

Google 클라우드 플랫폼

로그인 Google 클라우드 플랫폼으로 이동

accounts.google.com

 

 

코드와 설명

시나리오를 생각해보자 가장 먼저해야할것은 우리 서비스를 이요하기 위해 사용자 구글 소셜 로그인 버튼을 클릭할 것이므로 버튼을 생성해야한다. 

 

1. 구글 로그인 버튼 구현하기

import React, { useEffect } from "react";

function App() {

  const GoogleLogin = () => {
    const googleURL =
      "https://accounts.google.com/o/oauth2/v2/auth?client_id=" +
      process.env.REACT_APP_GOOGLE_ID +
      "&redirect_uri=" +
      process.env.REACT_APP_GOOGLE_REDIRECT_KEY +
      "&response_type=code" +
      "&scope=email profile";

    window.location.href = googleURL;
  };

  return (
    <div
      className="d-flex justify-content-center"
      style={{ marginTop: "300px" }}
    >
     <button onClick={GoogleLogin}>구글 로그인</button>
    </div>
  );
}

export default App;

 

위 googleURL은 구글의 로그인 페이지로 이동하는 url 입니다. 

해당 링크로 window.locaiton.herf 를 통해 googleURL  로. 이동하는 코드입니다.

이 과정이 전체 흐름도에서 1번에 해당하는 과정입니다.

 

2. 인가코드 받기

구글 콘솔에 등록한 redirect_url 이 바로 인가코드를 받을 url이다.

http://내가등록한 redirect_url?code=34F@FD#@G$#Y 이런식으로 파라미터에 code값이 넘어올것이다.

 

그렇다면 여기서 해야할 작업은 해당 redirect_url을 라우터로 설정하고 해당 컴포넌트에서 code값을 받고 서버에 보내주는것이다.

내가 구글 콘솔에 등록한 redirect_url 은 http://localhost:3000/googleLogin이다. (* 실제 도메인이 나오면 도메인 주소도 추가로 올려줘야한다. )

아래 코드에서 보면 path 가 상대경로로 /googleLogin 이라고 되어있다.

element는 GoogleLogin 이라는 컴포넌트를 작성했는데 해당 컴포넌트 코드는 밑에서 자세히 보여주겠다.

import React from "react";
import ReactDOM from "react-dom/client";
import "@coreui/coreui/dist/css/coreui.min.css";

//rotuer lib
import { createBrowserRouter, RouterProvider } from "react-router-dom";

//component
import App from "./App";
import GoogleLogin from "./GoogleLogin";


const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
  },
  {
    path: "/googleLogin",
    element: <GoogleLogin />,
  }
  
]);

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <RouterProvider router={router} />
);

 

GoogleLogin 컴포넌트 코드는 다음과 같다.

 

import React, { useEffect } from "react";

//lib
import axios from "axios";
import { useNavigate } from "react-router-dom";

//session
import useSessionStore from "./view/store/useSessionStore";

const GoogleLogin = () => {
  const queryParams = new URLSearchParams(window.location.search);
  const googleCode = queryParams.get("code");

  const navigate = useNavigate();
  const { updateUserInfo } = useSessionStore();

  useEffect(() => {
    const postCodeToServer = async () => {
      const response = await axios.get(
        `${process.env.REACT_APP_API_SERVER}/api/oauth/google?code=${googleCode}`
      );
      updateUserInfo(response.data.res_data);
      navigate("/afterLogin");
    };

    postCodeToServer();
  }, []);

  return (
    <>
      <div>Hello google callBackUrl</div>
    </>
  );
};

export default GoogleLogin;

이렇게 클라이언트에서 code를 서버에까지 전달하면 클라이언트는 할거 다한셈인데

마지막으로 서버에서 반환될 jwt 토큰값과, 사용자 정보를 반환받아 세션에 저장하는 코드만 추가로 작성하면 완료이다.

 

추가로 process.env 로 시작하는 변수에 대해 잘모른다면  해당 내용은 이 포스터를 확인해서 환경변수에 대해 알면 좋을것 같다.

https://junghan49.tistory.com/6

 

[REACT] dotenv 환경변수 설정, 빌드 환경변수

React 빌드 환경변수 설정 현 프로젝트에서 빌드 시 동적으로 도메인 명을 바꾸는 작업이 필요했다. 개발에서는 http://localhost:8080 실제 서비스 운영에는 도메인을 올릴 거 기 때문에 서버에 요청하

junghan49.tistory.com

 

3. spring 서버에서 코드값을 받아 처리 작업

 @GetMapping("/oauth/google")
    public ApiResponse<?> googleLogin(@RequestParam String code) {

        String accessToken = clientService.requestGoogleToken(
                "authorization_code",
                googleClientId,
                googleSecret,
                googleRedirectUrl,
                code
        );

        GoogleUserInfo userInfo = clientService.getGoogleUserInfo(accessToken);

        String jwtToken = gen.generateToken(userInfo.getName(), userInfo.getEmail(), LoginType.google.name(), jwtSecret);
        
        
       	return ApiResponse.of(jwtToken);
        
        }

 

스프링에서 get 방식으로 파라미터로 전달받은 code값을 clientService 객체를 통해 구글 플랫폼에 토큰을 받고 

해당 토큰으로 다시 사용자의 정보를 받은 코드  

마지막으로 해당 정보를 바탕으로 jwt를 생성하고 반환하는 코드이다. 

 

이제 clientService 코드를 잘 살펴보자.

	
    
    private final GoogleFeignClient googleFeignClient;

    private final GoogleFeignUserClient googleFeignUserClient;
 
 
 public String requestGoogleToken (String grantType, String clientId, String clientSecret, String redirectUri, String code) {
        log.info("check value [{}] [{}]", clientId, clientSecret);
        return commUtils.extractAccessToken(googleFeignClient.getToken(grantType, clientId, clientSecret, redirectUri, code));
    }

    public GoogleUserInfo getGoogleUserInfo(String accessToken) {
        String authorization = "Bearer " + accessToken;
        return googleFeignUserClient.getGoogleUserInfo(authorization);
    }

 

FeignClient 인터페이스에서 한다리 건네는 코드 정도이다 사실 현재 코드가 수정할 일이 없다고 한다면.. 굳이 service 까지 작성할 필요 없을꺼 같긴하다 딱히 볼 코드는 없고 바로 FeignClient 코드로 넘어가자

 

@FeignClient(name = "google", url = "https://oauth2.googleapis.com")
public interface GoogleFeignClient {
    @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    String getToken(
                    @RequestParam("grant_type") String grantType,
                    @RequestParam("client_id") String clientId,
                    @RequestParam("client_secret") String secret,
                    @RequestParam("redirect_uri") String redirectUri,
                    @RequestParam("code") String code);

    @PostMapping(value = "/revoke", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    String googleLogout(@RequestParam String token);

}

이런식으로 FeignClient는 인터페이스만 작성해주면 실제 코드는 내부적으로 작동하는 원리이다..
지금 블로그를 작성하면서 느끼는점은 진짜 service 필요없겠다 싶다. 왜작성했지..

 

이렇게 구글 소셜 로그인은 마무리 하겠다.

FeignClient를 사용해서 간단하게 토큰 요청, 토큰으로 사용자 정보 요청을 해보았다. 
나머지 해당 유저가 우리 서비스에 존재하는지 확인하고 없다면 바로 회원가입을 서버에서 진행하면 되고 
존재한다면 jwt 토큰을 생성해서 클라이언트단에 반환해주면 완성이다. 

 

내가만난 에러.. cors error
물론 다양한 에러 처리에도 신경써야 한다, 가장 골치 아팠던 에러는 cors 에러였다. 하지만 대부분의 cors 에러는 다음과 같이 해결할 수 있다. 

@Slf4j
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Value("${server.servlet.session.timeout}")
    private int sessionTime;

    @Override
    public void addCorsMappings(CorsRegistry registry){
        registry.addMapping("/**")		            //허용 맵핑 패턴             
                .allowedOrigins("요청하고자 하는 origin 출처 ex) http://localhost:3000")
                .allowedMethods("GET", "POST", "OPTIONS") //허용 메소드
                .allowedHeaders("*") // 허용하는 헤더 정보
                .allowCredentials(true) // 헤더에 토큰 같은 데이터를 담고 요청 보낼때 허가 
                .maxAge(sessionTime); // session 유지 시간 설정.
    }

 

대부분의 cors 에러는 sop (same-origin-pricple) 규정에 어긋나서 일어나는 일이기 때문에 서버에서 요청하는 origin 정보를 잘 입력해주면 된다. cors 에러 말고는 딱히.. 해결하기 어려웠던 에러는 없었던거 같다!