Joonas' Note

Joonas' Note

[FastAPI + React] 소셜 로그인 구현하기 - 구글 로그인 본문

개발

[FastAPI + React] 소셜 로그인 구현하기 - 구글 로그인

2022. 9. 18. 15:06 joonas

    아래 글에서 이어지는 내용입니다.

     

    [FastAPI + React] 소셜 로그인 구현하기 - 이메일 로그인

    아래 글에서 이어지는 내용이다. [FastAPI + React] 소셜 로그인 구현하기 - 기본 환경 구축 들어가기 전에 React는 CRA(Create React App)를 통해 생성한 어플리케이션을 사용하며, 여기서는 5.0.1 버전을 사

    blog.joonas.io


    들어가기 전에

    FastAPI에서 구글 로그인을 지원하는 많은 패키지들이 있다. 우리가 설치한 FastAPI Users에서도 지원하는 방법이 있다.

    하지만 이제까지 검색해서 확인한 방법들은 상황이 달랐다. FastAPI 에서 라우팅을 한다고 가정하고 작성된 방법들이 많았었고, 그래서 구글 인증 후에 돌아오는 redirection에 문제가 있었다.

    즉, 기존의 OAuth 2.0 방법들은 전통적인(?) 아래의 순서도를 따른다.

    풀스택의 OAuth2.0 인증 로직

    이 경우는 구글 로그인 후 다시 돌아와도 구글의 API를 사용할 수 있는 시크릿 값(Google Client ID, Google Client Secret)을 서버에서 알기 때문에 가능한 것이다.

    하지만 우리는 다시 돌아온 React 앱에는 그런 중요한 정보는 없다. 그렇기 때문에 아래와 같이 백엔드를 한번 더 거쳐야한다.

    백엔드에서 이루어지는 토큰 요청과 발급

    다행히도 구글 API 서버와의 통신을 FastAPI Users로 간단하게 사용할 수 있다. 기존의 이메일 인증에 사용한 JWT 인증 시스템과도 연결할 수 있으니 이 방법으로 진행하려고 한다.

    직접 Authlib, google-auth 등을 이용해서 구현해보기도 했는데, JWT 생성까지는 잘 되는데 기존 시스템으로의 로그인이 잘 안되었다. 그래서 위 방법을 알아봤는데 다행히도 커스터마이징이 조금 가능했다. 자세한 내용은 글의 마지막에 다시 다루도록 한다.


    구글 앱 설정

    사용자가 구글을 통해 로그인하기 위해서는, 사용자에게 "당신이 이 곳에 로그인하려고 합니까?"라는 질문이 가능하도록 구글에도 사이트의 정보를 등록해야한다.

    이 내용은 구글 클라우드 사이트에서 해야할 일이 많으므로, 다른 블로그의 글로 대체한다.

    한국어 - https://hyeo-noo.tistory.com/223
    영어 - https://blog.hanchon.live/guides/google-login-with-fastapi/


    FastAPI

    먼저 FastAPI Users의  get_oauth_router를 사용해서 구글 로그인이 연동되는 OAuth 라우터를 생성한다.

    #######################
    # google.routes.py
    
    from app.configs import Configs
    from httpx_oauth.clients.google import GoogleOAuth2
    
    from .libs import fastapi_users, auth_backend   # 이메일 연동할 때 쓰던 변수
    
    google_oauth_client = GoogleOAuth2(
        client_id=Configs.GOOGLE_CLIENT_ID,
        client_secret=Configs.GOOGLE_CLIENT_SECRET,
        scope=[
            "https://www.googleapis.com/auth/userinfo.profile", # 구글 클라우드에서 설정한 scope
            "https://www.googleapis.com/auth/userinfo.email",
            "openid"
        ],
    )
    
    google_oauth_router = fastapi_users.get_oauth_router(
        oauth_client=google_oauth_client,
        backend=auth_backend,
        state_secret=Configs.SECRET_KEY,
        redirect_url="http://localhost:3000/login/google",  # 구글 로그인 이후 돌아갈 URL
        associate_by_email=True,
    )
    
    #######################
    # main.py
    
    app = FastAPI()
    app.include_router(google_oauth_router, prefix="/auth/google", tags=["auth"])

    fastapi_users와 auth_backend 는 이메일 로그인을 구현하면서 선언한 변수와 동일한 것이다. 자세한 내용은 튜토리얼을 참고해서 복사해도 된다.

    주의할 점은, 라우터의 redirect_url 값이 구글 클라우드에서의 설정과 동일해야 구글의 로그인 페이지로 넘어간다.

    추가된 구글 OAuth 엔드포인트들

    이제 글의 서론에서 언급했던 2개의 엔드포인트가 자동으로 생겼다. /auth/google/authorizeURL을 생성해주는 API이고, /auth/google/callback은 구글 로그인으로부터 넘어온 값(code)을 그대로 다시 전달해주면 토큰을 발급해주는 API이다.

    여기까지만 해도 토큰 발급부터 그 토큰으로 유효한 JWT 인증까지, 백엔드는 준비가 끝났다.

    구글의 유저 프로필 사진을 가져오는 등의 작업이 필요 없다면, 아래의 내용은 무시하고 바로 React 에서 작업을 시작해도 된다.

    로그인 이후 커스텀 작업

    여기까지는 OpenID 스펙만 맞추기때문에, 구글 계정의 정보를 받아오지는 않는다. 요청할 때 scope에 userinfo.profile을 넣었지만, 발급받은 토큰을 통해서 별도로 유저 정보를 재요청해야 받을 수 있다.

    로그인 했을 때 DB에 저장된 내용에는 토큰 내용과 email 정도밖에 없다

    Google의 API 패키지들을 살펴보면, 유저 정보를 가져올 때 요청하는 URL을 찾을 수 있다. 이 URL을 사용해서 발급받은 토큰 직접 요청하면 given_name, family_name, picture 등 프로필 사진과 같은 유저 정보를 받을 수 있다.

    콜백 추가

    로그인 이후에 별도의 작업을 하기 위해 기존의 auth_backend가 아니라, 상속한 클래스로 만든 backend를 사용한다.

    from datetime import datetime
    from typing import Any, Optional
    
    import requests
    from fastapi import Response
    from fastapi_users.authentication import AuthenticationBackend
    from fastapi_users.authentication.strategy import Strategy
    
    from ..exceptions import BadCredentialException
    from ..libs import bearer_transport, get_jwt_strategy
    from ..models import User
    
    
    class GoogleAuthBackend(AuthenticationBackend):
        async def login(self, strategy: Strategy, user: User, response: Response) -> Any:
            strategy_response = await super().login(strategy, user, response) # 기존의 로그인 함수는 그대로 동작하도록 유지
            token = self.get_google_access_token(user)
            userinfo = get_profile_from_google(token)
            user.first_name = userinfo.get('given_name')
            user.last_name = userinfo.get('family_name')
            user.picture = userinfo.get('picture')
            user.last_login_at = datetime.now()
            await user.save()
            return strategy_response
    
        def get_google_access_token(self, user: User) -> Optional[str]:
        	"""
            	User에 추가했던 List[OAuthAccount] 필드를 활용한다.
            	google로 연동한 내용의 access_token을 가져온다.
            """
            for account in user.oauth_accounts:
                if account.oauth_name == 'google':
                    return account.access_token
            return None
    
    
    def get_profile_from_google(access_token: str) -> dict:
        response = requests.get(url="https://www.googleapis.com/oauth2/v3/userinfo",
                                params={'access_token': access_token})
        if not response.ok:
            raise BadCredentialException(
                'Failed to get user information from Google.')
        return response.json()
    
    
    # 새로 만든 구글 로그인용 인증 backend
    auth_backend_google = GoogleAuthBackend(
        name="jwt-google",
        transport=bearer_transport,
        get_strategy=get_jwt_strategy,
    )

    이메일에서는 그대로 사용했던 AuthenticationBackend 클래스를 상속받아서, login 함수를 오버라이딩하면 된다.
    (아쉽게도 이런 내용은 FastAPI Users 문서에 적혀있지 않다.)

    그 과정에서 생성된 모델(User)도 파라미터로 함께 넘어오므로, 여기서 중간에 유저 프로필 정보를 구글에 요청해서 받아온 다음 모델에 적고 저장하면 된다.

    이제 라우터 생성 부분을 아래처럼 바꾸기만 하면 끝난다.

    google_oauth_router = fastapi_users.get_oauth_router(
        oauth_client=google_oauth_client,
        backend=auth_backend_google,       # 구글 로그인을 위해 오버로딩한 backend로 교체
        state_secret=Configs.SECRET_KEY,
        redirect_url="http://localhost:3000/login/google",
        associate_by_email=True,
    )

    React

    먼저 리액트쪽에도 로그인 이후의 콜백을 받을 라우팅을 준비한다. /login은 로그인 화면이 이미 있고, 위에서 콜백 URL을 /login/google로 지정했으니 다음과 같이 설정한다.

    <Routes>
      <Route path="/login">
        <Route index element={<Auth.Login />} />
        <Route path="google" element={<Auth.Redirects.Google />} />
      </Route>
      <Route path="/logout" element={<Auth.Logout />} />
      <Route path="/my" element={<MyPage />} />
      <Route path="*" index element={<Home />} />
    </Routes>

    그리고 그 라우트와 연결되는 페이지를 작성한다. Auth.jsx를 다음과 같이 작성했다.

    // Auth.jsx
    
    const CallbackGoogle = () => {
      const location = useLocation();
      const navigate = useNavigate();
      const [text, setText] = useState('Waiting for Google Sign-in to complete...');
    
      useEffect(() => {
        customAxios()
          .get('/auth/google/callback' + location.search)
          .then(({ data }) => {
            console.log('Recieved data', data);
            setText(`Token: ${data.access_token}`);
            // access_token 토큰을 받아서 로그인 상태를 업데이트한다.
            // login({ token: data.access_token, });
          })
          .catch(({ response }) => {
            console.error(response);
            // 오류 처리
            setText('Failed to sign-in with Google');
          });
      }, [location]);
    
      return (
        <LoginContainer>
        	<p>{text}</p>
        </LoginContainer>
      );
    };
    
    export default {
      Redirects: {
        Google: CallbackGoogle,   // <Auth.Redirects.Google /> 로 사용 가능하도록 export
      },
    };

    컴포넌트가 로드되면, aixos를 사용해서 그대로 /auth/google/callback 에 받았던 GET 파라미터를 묶어서 그대로 전달하다시피 요청하는데, aixos 연결 대신에 구글로부터 넘어온 GET 파라미터를 출력해보면 아래와 같다.

    지정한 redirect_url 로 데이터와 함께 페이지가 돌아왔다.

    URL 뒤로 쿼리들이 길게 붙어있다. 값을 찍어보면 위 스크린샷과 같이 나오는데, 여기서 토큰을 발급 받기 위해 필요한 정보는 code 이다.

    백엔드의 /auth/google/callback에 대한 내용은 FastAPI에서 구현했기 때문에, 응답으로 access_token 값만 잘 받는다는다고 생각하면 된다.

    이렇게 받은 access_token은 로컬 스토리지에 잘 저장해두고, 인증을 확인해야하는 순간마다 꺼내서 사용하면 된다.

    실제로 로그인 관련 함수를 아래와 같은 형태로 작성해서 사용하고 있다.

    const getToken = () => localStorage.getItem('access_token');
    
    const updateToken = (new_token) => {
      if (new_token !== null) {
        storage.set('access_token', new_token);
      } else {
        storage.remove('access_token');
      }
    };
    
    const authenticate = async (access_token) => {
      if (access_token) {
        const result = await customAxios({
          headers: {
            access_token,
            Authorization: `Bearer ${access_token}`,
          },
        })
          .get('auth/authenticated-route')
          .then(() => true)
          .catch(() => false);
        updateToken(result ? access_token : null);
        return result;
      } else {
        updateToken(null);
        return false;
      }
    };
    
    const login = async ({ token }) => {
      await authenticate(token);
      navigate('/my');
    };

    인증이 필요한 페이지에서는 아래처럼 로컬 스토리지에서 토큰을 꺼내서 확인하고,

    const [isAuthed, setAuthed] = useState(false);
    
    useEffect(() => {
      setAuthed(authenticate(getToken()));
    }, [location]);

    구글 콜백 페이지에서처럼, access_token을 사용해서 로그인 하는 경우에는 아래와 같이 호출해서 사용한다.

    customAxios()
      .get('/auth/google/callback' + location.search)
      .then(({ data }) => {
        login({
          token: data.access_token,
        });
      });

    결과

    구글에 등록된 이름과 프로필 사진을 가져오는 것까지 잘 되는 것을 확인했다.

    구글 로그인 테스트

    코드

    전체 코드는 아래의 GitHub repository에서 확인할 수 있다.

    https://github.com/joonas-yoon/fastapi-react-oauth2/tree/signin-with-google

     

    GitHub - joonas-yoon/fastapi-react-oauth2: FastAPI + MongoDB with Beanie + React Login Example

    FastAPI + MongoDB with Beanie + React Login Example - GitHub - joonas-yoon/fastapi-react-oauth2: FastAPI + MongoDB with Beanie + React Login Example

    github.com

     

    Comments