본문 바로가기
개발관련/웹 개발(FullStack)

[FastAPI + React] 소셜 로그인 구현 (3) FastAPI

by yjoo_ 2023. 9. 29.

github - https://github.com/sku-likelion11th/sportFestival_wave_front.git

 

GitHub - sku-likelion11th/sportFestival_wave_front: 2023 영암체전 波動

2023 영암체전 波動. Contribute to sku-likelion11th/sportFestival_wave_front development by creating an account on GitHub.

github.com

웹 페이지 - https://wave-renew.sku-sku.com 

 

혹시 2023 영암체전을 아세요?

파동동동 너도 참여해! 파동동동

wave-renew.sku-sku.com

이번에는 구글 소셜 로그인 FastAPI 백엔드 측 처리를 다뤄보도록 하겠다.

 

물론 내가 깃허브에 구현한 코드와는 다르고 정리된 방법이니 코드를 읽을 땐 아 이렇게 구현했구나~ 생각하면 편하다.


이번에는 FastAPI측 코드를 작성해보도록 하겠다.

물론 이번 편에서는 FastAPI 코드만 작성하는 것이 아니라, React에서 POST 요청을 보내는 부분까지 작성할 것이다.

사용하는 database는 sqlite3로 sqlalchemy를 사용해 데이터 베이스를 생성하고, 사용자 정보를 저장하는 부분까지 만들어 보도록 하겠다.

 

mkdir backend && cd backend

프로젝트 폴더를 만들고 이동한다.

mkdir database models routes && touch {database,routes,models}/__init__.py

그 다음 디렉토리를 만들고 각 디렉토리에 __init__.py를 만들어준다

마지막으로 최상위 디렉토리에 main.py를 만들어주면 다음과 같은 구조가 만들어진다.

__init__.py를 왜 만들지?

__init__.py는 Python 3.3 이후부터는 필수적인 파일이 아니게 되었으나 하위 버전 간의 호환성과 패키지의 명확성을 위해 생성하는 것을 권장한다.

사실 개발하는 데 있어 그리 중요한 것은 아니다.

 

다음은 필요한 라이브러리를 설치할건데, 필요한 순간에 또 설치할 예정이니 가상환경을 잘 만들어두자.

~/Blog/backend ⌚ 16:38:49
$ python3 -m venv venv

~/Blog/backend ⌚ 16:39:01
$ source venv/bin/activate

가상환경에 접속했다면 fastapi와 uvicorn을 설치해준다.

~/Blog/backend ⌚ 16:39:57
$ pip install fastapi "uvicorn[standard]"

설치가 되었다면 main.py를 작성해주자.

 

from fastapi import FastAPI

# FastAPI
app = FastAPI()

if __name__ == "__main__":
	import uvicorn
	uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
    #python main.py를 실행하면 uvicorn을 실행한다.

python main.py로 실행하면 FastAPI서버가 실행된다.

 

이제 database를 연결해보도록 하겠다.

 

database/connetion.py를 만들어준다.

pip install "SQLAlchemy<=1.4.41" SQLModel

그리고 SQLAlchemy와 SQLModel 라이브러리를 설치해준다.

SQLModel은 SQLAlchemy를 기반으로 Model을 정의할 때 사용된다. SQLAlchemy를 좀 더 쉽게 사용하기 위한 라이브러리인 셈이다.

SQLModel을 사용할 땐 SQLAlchemy의 버전을 맞춰줘야 한다.

 

connetion.py를 다음과 같이 작성해준다.

from sqlmodel import SQLModel, create_engine, Session

# 데이터 베이스 파일 이름 지정
database_file = 'my_website.db' 
# DB 연결, MySQL의 경우 mysql://user:password@localhost/mydatabase 형식을 맞춰주면 된다.
database_connetion_string = f"sqlite:///{database_file}"

engine_url = create_engine(database_connetion_string, echo=True)

# 데이터베이스 테이블 생성하는 함수
def conn():
	SQLModel.metadata.create_all(engine_url)

# Session 사용 후 자동으로 종료
def get_session():
	with Session(engine_url) as session:
		yield session

DB 연결이 끝났다.

 

이제 main.py에서 서버가 실행되면 데이터베이스를 만들도록 만든다.

from fastapi import FastAPI
from database.connetion import conn

# FastAPI
app = FastAPI()

#애플리케이션이 시작 될 때 데이터베이스를 생성하도록 만듬
@app.on_event("startup")
def on_startup():
	conn()

if __name__ == "__main__":
	import uvicorn
	uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

다음과 같이 수정해준다.

 

그러고 나서 서버를 실행하면...

ㅋㅋ

빈 깡통이 만들어진다.

파일 내용이 확인하고 싶다면 vscode의 SQLite3 Editor같은 익스텐션을 사용하자.

 

이번엔 DB에서 사용할 users 테이블을 정의해보도록 하겠다.

models/users.py를 다음과 같이 수정한다

from pydantic import BaseModel, EmailStr
from sqlmodel import Field, SQLModel


class User(SQLModel, table=True):
	email: EmailStr = Field(primary_key=True)
	username: str
    # exp는 사실 저장할 필요가 없다. post용 모델을 따로 선언해주기 귀찮아서 선언했다.
	exp: int
    # 로그인 유저 인증은 구글에서 해주니까 비밀번호는 필요 없다.

여기서 EmailStr이라는 형식을 지정해주었는데, 이 형식을 사용하기 위해서는

pip install "pydantic[email]"

라이브러리를 설치해주어야 한다.

 

table을 True로 설정해주지 않으면 DB에서 테이블을 만들지 않으므로 꼭 추가해주도록 하자.

 

유저 모델도 만들었으니, 마지막으로 회원가입을 처리하는 함수를 만들어준다.

 

"""

routes/users.py

"""
from fastapi import APIRouter


user_router = APIRouter(
	tags=["User"],
)

"""

main.py

"""
from fastapi import FastAPI
from database.connetion import conn
from routes.users import user_router

# FastAPI
app = FastAPI()

# 라우터 등록
app.include_router(user_router, prefix="/user")

#애플리케이션이 시작 될 때 데이터베이스를 생성하도록 만듬
@app.on_event("startup")
def on_startup():
	conn()

if __name__ == "__main__":
	import uvicorn
	uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

 

routes/users.py에 user_router를 선언해주고 main.py에 라우터를 등록해준다.

 

JWT 인증 방식

JWT 인증 방식을 사용할 예정인데, JWT 토큰에 대해 간단하게 설명해보자면,

JWT(Json Web Token)은 다음 세가지로 구성 되어 있다.

  • Header
  • Payload
  • Signature

이 3가지는 점으로(.) 구분되어 있다.

 

토큰이 어떻게 생겨먹었는지는 더보기를 눌러보면 알 수 있다.

더보기

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoianltOTgwOUBnbWFpbC5jb20iLCJleHBpcmVzIjoiMjAyMy0wOS0yOCAxNjoyNjozNiJ9.LMPIvKRqKvFr1DAE5Wlc_czXAwEJM7lUYX68pgtOcW4

구글에서 발행한 token

Header에는 토큰 타입과 암호화 알고리즘이 명시되어 있고, Payload엔 유저의 정보들이 작성되어 있다.

 

  1. iss (Issuer) : 토큰 발급자
  2. sub (Subject) : 토큰 제목 - 토큰에서 사용자에 대한 식별값이 됨
  3. aud (Audience) : 토큰 대상자
  4. exp (Expiration Time) : 토큰 만료 시간
  5. nbf (Not Before) : 토큰 활성 날짜 (이 날짜 이전의 토큰은 활성화 되지 않음을 보장)
  6. iat (Issued At) : 토큰 발급 시간
  7. jti (JWT Id) : JWT 토큰 식별자 (issuer가 여러명일 때 이를 구분하기 위한 값)

각 값이 의미하는바는 이렇다.

exp의 만료시간은 다음 사이트에서 계산할 수 있다. https://semalt.tools/ko/timestamp

 

 

글쓰는 와중에 만료되어 버렸다.

 

payload에 유저의 중요한 정보를 담지 않는다. 전화번호나, 주소같은 민감한 정보를 담았다간 탈취되어 악용될 가능성이 높다.

 

그래서 payload에는 유저를 인증하기 위한 최소 정보만 담는다. 유저명, 만료시간정도만

그런데 그렇게되면 보안 토큰으로써 작동이 가능한가? 에 대한 의문이 남게되는데

이것은 마지막 Signature에 담긴다

 

Signature에 서버가 가지고 있는 sercret_key를 담는다.

서버가 가지고 있는 비밀키이기 때문에 서버가 아닌 다른 클라이언트는 이 값을 복호화 할 수 없다.

그래서 변조된 토큰이 넘겨져도 서버에서 만든 Signature가 아니면 암호화를 풀 수 없다.

 

따라서 이제 우리가 만들어야 하는건

1. Token을 생성하는 함수

2. Token을 복호화 하는 함수

3. 복호화 된 Token이 유효한지 검증하는 함수

4. 로그인시 생성된 Token을 사용자에게 돌려주는 함수(user_router의 login 함수)

 

4가지를 만들면 된다. 갈 길이 정말 멀다!!

 

mkdir auth && cd auth && touch __init__.py
pip install "python-jose[cryptography]"

먼저 jwt를 생성하는 함수부터 만들어보자.

python-jose[cryptograhy]는 JWT를 만들기 위한 라이브러리다. 설치해서 사용하도록 하자.

 

가장 먼저 해주어야 할 일은 Signature에 들어갈 비밀 키를 프로젝트에 추가하는 것이다.

하지만 이 것에도 문제가 하나 있다.

 

Github에 관리될 프로젝트라면 당연히 비밀 키는 로컬에만 따로 숨겨서 사용해야 한다.

로컬에 .env파일을 만들고 형상관리에서 제외하여 사용해주어야 한다.

.env에 비밀키를 입력해 사용하도록 하자.

 

database/connetion.py를 수정해준다.

from typing import Optional
from pydantic import BaseSettings
from sqlmodel import SQLModel, create_engine, Session

# 데이터 베이스 파일 이름 지정
database_file = 'my_website.db' 
# DB 연결 MySQL의 경우 mysql://user:password@localhost/mydatabase 형식을 맞춰주면 된다.
database_connetion_string = f"sqlite:///{database_file}"
connect_args={"check_same_thread": False}
engine_url = create_engine(database_connetion_string, echo=True, connect_args=connect_args)

# Setting config load
class Settings(BaseSettings):
	SECRET_KEY: Optional[str] = None
	DATABASE_URL: Optional[str] = None
	class Config:
		env_file = ".env"

# 데이터베이스 테이블 생성하는 함수
def conn():
	SQLModel.metadata.create_all(engine_url)

# Session 사용 후 자동으로 종료
def get_session():
	with Session(engine_url) as session:
		yield session

 

Settings 클래스를 추가해준다. Settings 클래스는 pydantic에서 지원하고 있다.

다음과 같이 선언해주면 SECRET_KEY와 DATABASE_URL을 숨겨진 .env 파일에서 가져올 수 있게되는데

MySQL을 사용하게 되면 host 계정, 비밀번호, 데이터베이스 주소 등을 입력해야 연결되므로 해당 값들을 숨길 때 유용하다.

 

프로젝트 최상위에 .env파일을 다음과 같이 만들어주자

SECRET_KEY='input_your_key'

비밀키는 본인이 직접 길게 만들어도 좋고, 랜덤 난수 값을 뽑아서 집어넣어도 좋다.

파이썬에는 secrets 라는 라이브러리가 있어서 그걸 사용하면 간단하게 비밀 키를 만들어 줄 수 있다.

 

간단하게 python 터미널을 켜고 다음 코드를 실행해보자

import string
import secrets
alphabet = string.ascii_letters + string.digits
password = ''.join(secrets.choice(alphabet) for i in range(32))
print(password)

랜덤 난수 값을 뽑아준다

이 값을 복사해 사용하면 된다.

 

자 이제 JWT를 만들어보자!

auth/jwt.py를 만들고 jwt를 만드는 함수를 작성한다.

from datetime import datetime, timedelta
from fastapi import HTTPException, status
from jose import JWTError, jwt
from pydantic import EmailStr
from database.connetion import Settings

# Settings 클래스를 인스턴스화 해서 .env 값을 가져온다.
settings = Settings()

# 토큰을 생성하는 함수
def create_access_token(user: EmailStr, exp: int):
	# 토큰을 생성할 때 user 이메일과 exp(만료시간)을 받아온다
	# 받아온 정보를 기반으로 payload를 작성한다. 필요한 정보만큼 저장하면 된다.
	payload = {
		"user": user,
		"expires": exp
	}
	# 작성된 payload와 secrets키, 암호화 알고리즘을 지정해준다.
	token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
	# 만들어진 토큰을 리턴한다.
	return token

# 토큰을 검증하는 함수
def verify_access_token(token: str):
	try:
    	# 토큰을 decode한 값을 data에 저장한다.
        # 이 단계에서 decode되지 않으면 당연히 검증된 토큰이 아니다.
		data = jwt.decode(token, settings.SECRET_KEY, algorithms="HS256")
        # 여기서부터 인증된 사용자의 토큰이 만료되었는지 체크한다.
		expires = data.get("expires")
		if expires is None:
			raise HTTPException(
				status_code=status.HTTP_400_BAD_REQUEST,
				detail="No access token supplied"
			)
		if datetime.utcnow() > datetime.utcfromtimestamp(expires):
			raise HTTPException(
				status_code=status.HTTP_403_FORBIDDEN,
				detail="Token expired!"
			)
        # 정상 토큰이라면 사용자 데이터를 리턴한다.
		return data
	except JWTError:
		raise HTTPException(
			status_code=status.HTTP_400_BAD_REQUEST,
			detail="Invalid token"
		)

토큰을 생성하고 검증하는 함수를 작성했다.

 

클라이언트에서 토큰을 가지고 요청할 때 토큰은 HTTP 요청의 Authorization 헤더에 위치한다.

우리가 방금 작성한 verify_access_token 함수는 토큰을 검증하기만 할 뿐이다.

따라서 HTTP요청을 받고, 요청 헤더의 토큰을 찾아서 verify_access_token 함수를 실행할 함수가 필요하다.

 

auth/authenticate.py를 새로 작성해주도록 하겠다.

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from auth.jwt import verify_access_token

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/user/login")

# 토큰을 인수로 받아 유효한 토큰인지 검사한 뒤 payload의 사용자 필드를 리턴함
async def authenticate(token: str = Depends(oauth2_scheme)) -> str:
	if not token:
		raise HTTPException(
			status_code=status.HTTP_403_FORBIDDEN,
			detail="Sign in for access"
		)

	decode_token = verify_access_token(token)
	return decode_token["user"]

OAuth2PasswordBearer는 위에서 설명한 HTTP Authorization 헤더의 토큰을 추출하는 함수다.

이 때 Authorization 헤더는 "Bearer {Token}"의 형식을 갖추어야 한다.

 

이제 이 함수는 클라이언트가 HTTP 요청을 보냈을때 함수를 실행하면 요청 헤더의 토큰을 찾아서 검증할 것이다.

 

이제 모든 준비가 끝났다.

마지막으로 유저를 생성하는 router를 만들어보겠다.

from fastapi import APIRouter, Depends, HTTPException, status
from auth.jwt import create_access_token
from database.connetion import get_session
from models.users import User


user_router = APIRouter(
	tags=["User"],
)

# Response를 TokenResponse 모델로 지정
@user_router.post("/login", response_model=TokenResponse)
async def login(body: User,session=Depends(get_session)) -> dict:
	# 로그인 유저가 DB에 있는지 검사한뒤
	existing_user = session.get(User , body.email)
	try:
    	# 있다면 토큰을 발행하고 리턴
		if existing_user:
			access_token = create_access_token(body.email, body.exp)
		else:
        # 없다면 DB에 저장하고 리턴
			session.add(body)
			session.commit()
			session.refresh(body)
			access_token = create_access_token(body.email, body.exp)
		return {
				"access_token": access_token,
				"token_type": "Bearer"
			}
	except:
		raise HTTPException(
			status_code=status.HTTP_400_BAD_REQUEST,
			detail="Bad Parameter",
		)

마지막으로 models/users.py에 TokenResponse 모델을 선언해준다.

from pydantic import BaseModel, EmailStr
from sqlmodel import Field, SQLModel


class User(SQLModel, table=True):
	email: EmailStr = Field(primary_key=True)
	username: str
	exp: int

class TokenResponse(BaseModel):
	access_token: str
	token_type: str

 

이제 로그인을 위한 준비가 끝났다.

마지막으로 React에서 로그인 시 백엔드로 요청을 보내고 token을 잘 받아오는지 테스트해보도록 하겠다.

 

리액트에서 axios 패키지를 설치해준다.

npm install axios

그 다음 GoogleLoginBtn.js에 다음과 같이 수정해준다.

 

import { GoogleLogin } from '@react-oauth/google'
import axios from 'axios'
import jwtDecode from 'jwt-decode'
import React from 'react'

export const GoogleLoginBtn = () => {
	const loginHandle = (response) => {
		const decode_token = jwtDecode(response.credential)
		// FastAPI 서버로 보낼 데이터 폼
		const data = {
			email: decode_token.email,
			username: decode_token.family_name + decode_token.given_name,
			exp: decode_token.exp
		}
		//post 요청을 보낸다.
		axios.post("http://localhost:8000/user/login", data,
			{
				headers: {
					'Content-Type': 'application/json'
				}
			}
		)
		.then(response => {
			// 성공적인 요청시 response 값을 localStorage에 저장한다.
			console.log(response)
		})
		.catch(error => {
			// 실패시 에러 메시지 출력
			console.log(error)
		})
	}
	return (
		<>
			<GoogleLogin
				onSuccess={loginHandle}
				onError={() => {
					console.log("Login Failed");
				}}
				width='300px' //버튼 크기 지정
				/>
		</>
	)
}

export default GoogleLoginBtn

 

이러고 로그인을 해보면

우리가 원하는 값이 돌아왔다!

데이터베이스에도 잘 저장이 되었다!

 

다음 시간엔 간단한 플래너 추가 모델을 만들어서 오늘 고생하며 만든 토큰을 사용해보도록 하겠다!

 

Reference
secrets — 비밀 관리를 위한 안전한 난수 생성
https://docs.python.org/ko/3/library/secrets.html
JWT(Json Web Token) 인증방식
https://velog.io/@jinyoungchoi95/JWTJson-Web-Token-%EC%9D%B8%EC%A6%9D%EB%B0%A9%EC%8B%9D
[Python] __init__.py
https://passwd.tistory.com/entry/Python-initpy