Joonas' Note

Joonas' Note

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

개발

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

2022. 9. 16. 21:44 joonas

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

     

    [FastAPI + React] 소셜 로그인 구현하기 - 기본 환경 구축

    들어가기 전에 React는 CRA(Create React App)를 통해 생성한 어플리케이션을 사용하며, 여기서는 5.0.1 버전을 사용했다. 나머지 모듈들은 package.json에서 확인할 수 있다. FastAPI는 0.83.0 버전을 사용했고,.

    blog.joonas.io


    이 글의 목표

    Backend는 FastAPI users를 사용해서 라우팅까지 전부 간단하게 구현이 끝났다.

    그럼 API에서 생성된 유저 관련 라우터들을, 아래와 같이 React에서 사용할 수 있도록 연동해보자.

    리액트에 로그인 폼 구현하고 제출해보기

    전체 내용

    앞으로 구현할 내용은 간단하다.

    리액트에서 폼을 만들어서, 백엔드의 로그인 엔드포인트로 요청하면, 받은 응답을 브라우저에 적절히 보여주는 것이다.

    로그인은 JWT(JSON Web Token)으로 처리할 것이라서, 백엔드에서 별도의 세션을 저장하거나 하는 일은 없다.

    이 글에서는 JWT에 대한 자세한 내용은 생략한다.


    FastAPI

    FastAPI-Users를 사용해서 라우터를 추가하면, 갑자기 많은 엔드포인트가 생긴 걸 볼 수 있다.

    # auth/routes.py
    
    from fastapi import APIRouter, Depends
    from .models import User, UserCreate, UserRead, UserUpdate
    from .libs import auth_backend, current_active_user, fastapi_users
    
    router = APIRouter()
    
    get_auth_router = fastapi_users.get_auth_router(auth_backend)
    get_register_router = fastapi_users.get_register_router(UserRead, UserCreate)
    get_reset_password_router = fastapi_users.get_reset_password_router()
    get_verify_router = fastapi_users.get_verify_router(UserRead)
    get_users_router = fastapi_users.get_users_router(UserRead, UserUpdate)
    
    routers = [
        (router, dict(prefix="/auth", tags=["auth"])),
        (get_auth_router, dict(prefix="/auth/jwt", tags=["auth"])),
        (get_register_router, dict(prefix="/auth", tags=["auth"])),
        (get_reset_password_router, dict(prefix="/auth", tags=["auth"])),
        (get_verify_router, dict(prefix="/auth", tags=["auth"])),
        (get_users_router, dict(prefix="/users", tags=["users"])),
    ]
    
    @router.get("/authenticated-route")
    async def authenticated_route(user: User = Depends(current_active_user)):
        return {"message": f"Hello {user.email}!"}
    # main.py
    
    from fastapi import FastAPI
    from fastapi.middleware.cors import CORSMiddleware
    
    from auth import routers
    
    app = FastAPI()
    
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["http://localhost:3000"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    
    for router, kwargs in routers:
        app.include_router(router=router, **kwargs)

    엔드포인트 확인

    이제 FastAPI 서버를 시작해서 라우터가 잘 추가되었는지 확인해보자. 아래의 명령어로 서버를 시작할 수 있다.

    python -m uvicorn main:app --reload

    FastAPI Users 튜토리얼에서 사용하는 모델을 별로 바꾼 것이 없다. 라우터를 묶어서 한번에 추가해주면 Docs에서 아래와 같은 엔드포인트들을 확인할 수 있다.

    로그인과 유저 관련해서 생성된 엔드 포인트들

    이 글에서는 아래 3개의 엔드포인트만 알면 된다.

    • /auth/register : 유저를 등록한다. email과 password가 적힌 JSON 형태를 body에 담아 요청한다. 성공 여부를 응답하고, DB 설정까지 마쳤다면 연결된 DB에 계정이 추가되는 것까지 확인할 수 있다.
    • /auth/authenticated-route : 로그인 상태를 확인한다. 헤더에 인증 정보를 적어서 전달해야하는데, 이건 docs에서는 어렵고 Post Man을 사용하거나 문서에서 보여주는 curl 명령어를 실행해보는 것을 추천한다.
    • /auth/jwt/login : username과 password를 form 형태로 body에 담아서 요청을 보내면, 로그인 성공 시 토큰과 인증 방법을 준다.

    간단한 CRUD 확인

    /auth/register 로 데이터를 보내서 유저를 등록해보자.

    문제 없이 성공한 경우에는, 위와 같은 JSON을 응답한다. 이미 있는 경우도 "REGISTER_USER_ALREADY_EXISTS" 같은 문구로 잘 처리해준다.

    DB에 추가된 것 까지 확인

    MongoDB를 연결했다면, User 라는 이름으로 collection을 자동 생성한다. User collection을 확인해보면 잘 추가된 것을 볼 수 있다.
    참고로 FastAPI-Users는 _id, email, hashed_password, is_active, is_superuser, is_verified 까지만 기본으로 제공하고 나머지 필드는 커스텀으로 추가한 필드이다.

    이제 생성한 email(= username)과 password를 /auth/jwt/login 로 던져보면, access_token을 받을 수 있다.

    /auth/jwt/login 응답 결과

    이 토큰을 헤더에 같이 적어서 /auth/authenticated-route 를 확인해보면 인증 결과를 확인할 수 있다.

    토큰 인증 확인 방법

    방법이 두 가지가 있다.

    하나는 FastAPI의 Interaction Docs에서는 오른쪽 상단의 [Authorize] 버튼을 사용해서 로그인하는 방법이다.
    이렇게 로그인하면 현재 Docs 페이지 세션에서 보내는 요청마다 헤더가 인증 정보가 추가된다.

    다른 방법으로는 직접 헤더에 적어서 보내는 방법이다. 이건 Postman이나 curl 명령어를 직접 사용하는 방법이다.
    리액트에서 헤더에 직접 적어서 보내야하므로 이 방법을 알고 가는 것을 추천한다.

    Postman으로 헤더에 인증 정보를 적어서 요청한 화면

    인증에 성공했다면 우리가 추가한 라우터의 authenticated_route() 함수 결과를 잘 받을 수 있다.

    시간이 지난 후 요청했을 때 서버 응답

    JWTStrategy에서 작성한 lifetime_seconds 시간이 지난 후에, 같은 토큰 값으로 다시 요청해보면 Unauthorized 결과를 응답한다.

    앞으로 이런 인증 API를 통해서 사용자의 로그인 정보와 현재 상태가 유효한 지 판단해야한다.

    CORS 미들웨어 추가

    허용하는 origins에는 리액트 프로젝트의 주소인 localhost:3000를 추가해준다. 실제 서비스할때는 이 부분도 환경변수로 처리할 수 있다.

    이걸 서버에서 작업해주지 않으면 나중에 리액트에서 아래처럼 오류가 난다.

    반갑다 CORS

    이제 리액트에서 구현해야 할 내용만 남았다.


    React

    aixos 설정

    백엔드를 별도로 운용하고 있기 때문에, React에서 일반적으로 axios를 사용하듯이 요청을 던지면, 리액트 앱과 같은 도메인으로 요청이 전송된다.

    예를 들어서, axios.post("/auth/jwt/login")를 호출하면, "localhost:8000/auth/jwt/login" 가 아닌 "localhost:3000/auth/jwt/login" 으로 전송되기 때문에 엉뚱한 응답만 돌아온다.
    axios에서는 baseURL 이라는 필드를 설정해서 이를 해결할 수 있다.

    axios를 사용하는 모든 곳에서 매번 헤더 설정과 baseURL 같은 설정을 하기는 번거로우므로, 아래와 같이 axios를 감싸서 사용하면 편하다.

    import axios, { AxiosInstance } from 'axios';
    
    const API_SERVER = 'http://localhost:8000/';
    
    const getAccessToken = () => {
      // 나중에 로그인이 성공하면, 이 이름으로 저장한다.
      return localStorage.getItem('access_token');
    };
    
    export const createAxios = (configs) => {
      const INITIAL_CONFIGS = {
        baseURL: `${API_SERVER}`,
        withCredentials: true,
        headers: {},
      };
      return axios.create(Object.assign(INITIAL_CONFIGS, configs));
    };
    
    export const customAxios = (configs) => {
      return createAxios(
        Object.assign(
          {
            headers: {
              access_token: getAccessToken(),
              Authorization: `Bearer ${getAccessToken()}`,
            },
          },
          configs,
        ),
      );
    };

    이제 아래와 같이 사용하면, 우리가 설정한 백엔드의 엔드 포인트로 전송한다.

    customAxios()
      .post('/auth/jwt/login', formData, {
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
      })
      .then((response) => { /*...*/ });
      
    // 설정을 오버라이딩해서 전송도 가능
    customAxios({ header: /*...*/ }).get(...)

    로그인 폼 컴포넌트

    MUI로 빠르고 이쁘게 만들 수 있다. 글에서는 전체를 전부 적기는 어려워서, 최대한 로직만 확인할 수 있도록 첨부했다.

    const validate = ({ username, password }) => {
      if (username.length < 1) return 'Username is required.';
      if (password.length < 1)  return 'Password is required.';
      return null;
    };
     
    const LoginForm = (props) => {
      const { onSubmit, serverResponse } = props;
     
      const [values, setValues] = useState({
        username: '',
        password: '',
        showPassword: false,
        errorMessage: null,
      });
     
      const setValue = (key, value) => {
        setValues({ ...values, [key]: value });
      };
     
      const handleChange = (prop) => (event) => {
        setValues({
          ...values,
          errorMessage: null,
          [prop]: event.target.value,
        });
      };
     
      const hasError = useMemo(() => {
        return !!values.errorMessage;
      }, [values.errorMessage]);
     
      useEffect(() => {
        setValue(
          'errorMessage',
          serverResponse.status === 'error' ? serverResponse.message : null
        );
        console.log(serverResponse);
      }, [serverResponse]);
     
      return (
        <Form
          onSubmit={(evt) => {
            evt.preventDefault();
            const err = validate(values);
            const isValid = err === null;
            setValue('errorMessage', err);
            if (isValid) {
              onSubmit(values.username, values.password);
            }
            return isValid;
          }}
        >
          <Input error={hasError}>
            <OutlinedInput
              type="text"
              value={values.username}
              onChange={handleChange('username')}
              label="Username"
            />
          </Input>
          <Input error={hasError}>
            <OutlinedInput
              type="password"
              value={values.password}
              onChange={handleChange('password')}
              label="Password"
            />
          </Input>
          {hasError && <Alert severity="error">{values.errorMessage}</Alert>}
          <LoginButton type="submit">Login</LoginButton>
        </Form>
      );
    };

    form element가 제출될 때, 컴포넌트 외부에서 onSubmit으로 아이디와 패스워드를 넘겨받아 처리할 수 있도록만 해준다.
    아래 그림과 같이 서버로의 제출 이후의 에러 상태를 표시해주기 위해서 serverResponse 도 받아오도록 하자.

    제출 이후에 서버로부터 받은 응답을 다시 보여줌

    서버와의 요청/응답 처리

    export const LoginPage = () => {
      const [serverResponse, setServerResponse] = useState({
        status: null,
        message: '',
      });
    
      const onSubmit = (username, password) => {
        const formData = qs.stringify({
          username, password,
        });
    
        setServerResponse({ status: 'waiting', });
    
        customAxios()
          .post('/auth/jwt/login', formData, {
            headers: { 'content-type': 'application/x-www-form-urlencoded' },
          })
          .then((response) => {
            const { access_token } = response.data;
            // 로컬 스토리지에서 토큰을 저장해놓고, 요청마다 헤더에 끼워 사용할 예정
            localStorage.setItem('access_token', access_token);
            setServerResponse({
              status: 'success',
              message: null,
            });
            // 위처럼 success 표시할 필요 없이, 여기서 페이지 이동을 해도 된다.
          })
          .catch(({ message }) => {
            console.error(`Failed to request : ${message}`);
            setServerResponse({
              status: 'error',
              message: 'Your login attempt was not successful. Try again',
            });
          });
      };
    
      return (
        <LoginContainer>
          <LoginCard>
            <Title>Login</Title>
            <SubTitle>Welcome back! Sign in to continue</SubTitle>
            <LoginForm onSubmit={onSubmit} serverResponse={serverResponse} />
          </LoginCard>
        </LoginContainer>
      );
    };

    결과

    API 서버로부터 인증 정보를 잘 받았고, 로그인 처리도 잘 되고 있다!

    백엔드에서 FastAPI Users로 작업이 다 끝났기 때문에, 사실상 리액트에서의 비동기 요청을 처리하는 것 밖에 없었다.

    다음 글에서는 Google 로그인을 구현한다.


    전체 코드

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

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

     

    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