[FastAPI와 React] OAuth 인증 사용하기 Authlib

THOVY·2023년 9월 16일
1

FastAPI서버와 React 클라이언트를 쓰는데 OAuth 인증 기능을 다시 구현해야했다.
기존에 사용하던 인증 패키지가 지원을 중단했기 때문이죠.

당연히 기존 회원가입, 로그인 모델을 유지해야하는데 이곳저곳의 글을 보니 내가 원하는 방식과 많이 달라서 겁을 먹었다.
하지만 멋진 나, 원하는 대로 만들어냄😉✌

이곳저곳 블로그 글을 보니 Authlib을 사용하면서 다른 패키지를 함께 사용하기도 하던데 나는 authlib만 사용했다.
다른 패키지를 쓰면 기존 모델을 바꿔야하더라고. 그런 거 용납 못 하지.
게다가 패키지 지원중단이라는 가슴 아픈 경험은 패키지 의존도를 낮추고 싶게한다.
사실 authlib도 없이 oauth 로그인 만들어보고 싶었다. 그래서 살짝 훑어봤음. 뭐 슬쩍 보고 눈 감긴 함.

Authlib docs는 매우 친절하게 잘 설명되어 있지만,
docs의 예제는 user를 바로 반환하고 있는데, react를 같이 쓰고 있는 나는 callback 메서드가 user를 바로 반환하면 안 되니 뭐 어찌저찌 사용할 수 있도록 만들어볼거다.

그래서 내가 한 일. callback 메서드를 수정해주자!

일단

참조

  • Authlib Docs
  • Authlib Blog
    authlib docs - fastapi 시작점에서 authlib starlette문서와 authlib web문서를 보고 오라 하니까 한 번 읽어보면 도움이 많이 된다. 사실 도움되는 정도가 아니라 읽어야 됨. fastapi 관련 문서가 굉장히 짧은데 왜냐면 다 생략되어있음..ㅋㅋㅋㅋㅋ처음이라면 starlette 과 web을 꼭 읽고 fastapi 를 읽자.

시작


기본적으로 docs와 blog를 따라만해도 잘 된다.

아 중요한 게 있는데

  1. client가 로그인버튼을 누르면, server가 자신만의 key(인증플랫폼에 웹 서비스를 등록하면 알려주는 client_key(id, secret))를 포함해 인증플랫폼의 login url을 반환해준다.
  2. client는 해당 login url로 이동하게 되고, user가 플랫폼에 로그인할 수 있는 화면을 출력한다.
  3. user가 login에 성공하면 인증플랫폼은 redirect_url로 user가 입력한 정보를 보내고 인증플랫폼의 token을 받아온다.
    이 때, redirect_url의 쿼리에 뭔가 포함되는데, state, code, scope 등이 포함된다.
  4. 플랫폼마다 token에 포함된 내용이나 형태는 제각각이지만 당연하게도 로그인한 사용자의 정보를 포함하고 있는 것은 모두 같다.
  5. 다음에 DB에 회원으로 등록하거나 엑세스토큰을 반환한다.

나는 플랫폼의 token이 아니라 기존 로그인과 같은 형태의 토큰을 반환하도록 했다. 포함되어야할 데이터들이 있으니까.
그게 client의 수정을 최소화할 것 같았다.

그리고 팝업창을 띄워서 사용하고 싶어서 팝업을 띄우도록 했다.

server(fastapi)

나는 google과 twitter를 이용하고 싶으므로 OAuth 클래스를 가져와서 두 객체를 만들어줄거다.

# 우리가 설치한 authlib. fastapi는 starlette 기반이므로 fastapi도 starlette을 사용하는 것 같다.
from authlib.integrations.starlette_client import OAuth
# docs에서는 사용하지 않는 거지만 HTMLResponse가 있어야 내가 원하는 형태로 돌려줄 수 있다.
from starlette.responses import HTMLResponse
# 중요한 정보기 때문에 숨겨놓고 씁시다. google과 twitter에서 받아오시다.
from app.config import TWITTER_CLIENT_ID, TIWTTER_CLIENT_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET

oauth = OAuth()

# twitter
oauth.register(
    name='twitter',
    client_id = TWITTER_CLIENT_ID,
    client_secret = TIWTTER_CLIENT_SECRET,
    api_base_url='https://api.twitter.com/1.1/',
    request_token_url='https://api.twitter.com/oauth/request_token',
    access_token_url='https://api.twitter.com/oauth/access_token',
    authorize_url='https://api.twitter.com/oauth/authenticate',
)

# google
oauth.register(
	name='google',
	client_id= GOOGLE_CLIENT_ID,
	client_secret= GOOGLE_CLIENT_SECRET,
	server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={
        'scope': 'openid email profile'
    }
)

url들은 공통된 것들이다. 각 플랫폼의 oauth가 진행될 때 사용되는 url이기 때문에 우리가 바꿔야하는 건 client_id와 secret뿐이다.
google에서는 client_kwargs를 수정할 수 있는데, google 인증을 사용해 회원가입/로그인한 회원에게서 가져오고 싶은 정보들을 적는 거다.

@app.get('/oauth/login/{provider}')
async def login_by_oauth(request: Request, provider:str):
	base_url = 'http://localhost:8000'
    # 우리가 만들어줄 callback 주소다. 인증이 완료되면 callback 주소가 호출된다.
	redirect_uri = f'{base_url}/oauth/login/{provider}/callback'
    
    # callback 주소를 담아 oauth 제공사들에 맞게 redirect를 요청
	return await oauth.create_client(provider).authorize_redirect(request, redirect_uri)
@app.get('/oauth/login/{provider}/callback')
async def auth_twitter(request: Request, provider:str):
	# 인증이 정상적으로 이뤄졌다면 인증된 유저의 정보들이 반환될 거다
	token = await oauth.create_client(provider).authorize_access_token(request)
    
    # provider에 따라 token을 dict를 반환하기도하고, json으로 반환하기도 한다.
    if provider == 'google':
			user_info = token['userinfo']
	elif provider == 'twitter':
    	url = 'account/verify_credentials.json'
        resp = await oauth.twitter.get(
        	url, params={'skip_status': True}, token=token)
		user = resp.json()
        user_info = user
    
    # 회원가입을 하거나 로그인을 하거나 controller를 통해서 작업후에 user를 반환시키자.
    save_result = await user_controller.oauth_create_or_get_user(provider, user_info)
    
    # user가 반환되면 client에서 사용할 token을 만들어주고 client로 반환하자.
    access_token = create_access_token(save_result)
    user_token = Token(
    	access_token=access_token,
        token_type='bearer'
    )
    
    return user_token
    

docs나 blog도 그렇고 보통 callback 메서드에서 user_token 을 반환하거나 할텐데 여기선 다르게 사용할거다.
조금있다가 return을 바꿔보자.

일단 클라이언트 코드를 짜봅시다.

client(react)

컴포넌트로 분리해서 함수형태로 작성할텐데 provider 마다 분리해서 할 수도 있지만, 합치면 편할겁니다.
나는 요청을 보내고 응답 redirect uri를 받아서 팝업으로 띄우고, 회원가입하고 로그인 성공하면 기존 로그인과 같이 token을 받는 것만 한다.

const Oauth = () => {

    const loginOauth = (provider:string) => {
      	// 요청할 server 주소
        const authUrlProvider = BASE_URL + `/users/oauth/login/${provider}`
    	
        // 팝업열기
        const popup = window.open(authUrlProvider, `${provider} Login - PANGCORN`, 'width=600,height=600');
      	// server에서 온 응답 메세지를 받아서
        window.addEventListener('message', (event) => {
            if (event.origin == BASE_URL){
                try{
                    const data = JSON.parse(event.data);
                    
                  	// login Success가 반환되면 localstorage에 저장.
                    if (data.type === 'loginSuccess') {
                        const userToken = data.token;
                        localStorage.setItem('userToken', userToken);
                    }
                    else{
                        alert('다시 시도해주세요')
                    }
                }
                catch(err){
                    console.log('Error', err);
                }
            }
          	// 끝나면 팝업닫고 홈으로. 팝업 닫은 뒤에 리프레쉬를 해도 됨(팝업이니까).
            popup?.close();
            window.location.href = '/'
        });
    };

  return (
    <center>
        <div>
                <button onClick={()=>loginOauth('twitter')} >
                        트위터로그인
                </button>
                <button onClick={()=>loginOauth('google')} >
                        구글로그인
                </button>
        </div>
    </center>
  )
}

export default Oauth

server가 HTMLResponse를 해서 token을 담아주도록 했다. 기존 로그인처럼 token을 반환하고 싶었지만 몬가 잘 안 됨 😢
아무튼 그래서

	...
    
		if save_result:
			access_token = create_access_token(save_result)
			user_token = Token(
				access_token=access_token,
				token_type='bearer'
			)

			response = f'''
				<script>
					var message = {{ type: 'loginSuccess', token: "{user_token}" }}
					
					window.opener.postMessage(JSON.stringify(message), 'http://localhost:3000');
				</script>
			'''
			return HTMLResponse(content=response)
		else:
			response = f'''
				<script>
					var message = {{ type: 'loginfail'}}
					
					window.opener.postMessage(JSON.stringify(message), 'http://localhost:3000');
				</script>
			'''
        	return HTMLResponse(content=response)

이렇게 HTMLResponse를 반환하도록 했다.

뭔가 부족하고 뭔가 엉성해서 좀 다듬어야 할 거 같긴한데
원하는 대로 작동은 하고 기존 가입/로그인 로직도 그대로 사용할 수 있으니 만족한다.
다듬긴해야할듯엉망진창2

Error ❌

간혹 잘 되던 oauth 인증이 문제가 발생하기도 했다.
어쩌다가 oauth 인증을 시도하면 500 에러페이지가 표시되는 경우가 있는데, 그럴 때 시크릿모드에서 인증을 시도하면 그런 문제가 안 생긴다. 아마 user가 인증을 요청한 해당 플랫폼에 이미 로그인된 상태이였을 때 뭔가 꼬이면서 에러가 튀어나오는 것 같다. session이나 뭐 좀 지워주면 스탠다드 모드에서도 잘 작동된다. 시크릿모드에서는 로그인이 되어있지도 않고 session도 없어 기존에 존재하는 user정보가 없으니 잘 되는 거 같음.

Solution ✅

않이 그렇다고 user들 보고 "우리 서비스에서 oauth인증을 사용하려면 툴툴대지말고 시크릿 모드를 사용하십시오." 라고 할 순 없잖슴?
우리의 callback 메서드에서 access token을 받아올 때 에러가 발생하는 것이므로 callback메서드에 try catch를 적절히 사용해서 해결할 수 있다.

진짜 끝

profile
BEAT A SHOTGUN

0개의 댓글