Python/FastAPI

FastAPI에서 JWT 로그인 기능 구현하기(회고)

emhaki 2023. 12. 16. 12:44
728x90
반응형
SMALL

IT 서비스를 이용하면서 로그인 기능은 흔히 볼 수 있는 기능이다. 가장 흔하고 구현을 위한 수 많은 라이브러리가 존재하기 때문에 쉽지만, 그만큼 보안에 있어서 가장 중요한 부분이기 때문에 신경써야 할 점들이 많다. 회사에서 내년 3월 일본에 테스트할 서비스를 만들면서 로그인 기능구현을 담당하게 됐다. 어떤 방식을 적용하면 좋을까 고민하게 됐었고, JWT와 AccessToken, RefreshToken을 이용하기로 했다. 이틀이라는 시간에 걸쳐 현재는 로그인 구현을 완료한 상황이지만 구현하는 과정에서 생겼던 의문점과 회고를 기록하고 싶다는 생각이 들었다.

FastAPI에 대한 이해

현재 다니고 있는 회사는 Python 기반의 FastAPI 프레임워크를 사용한다. FastAPI는 어플리케이션을 빠르게 개발할 수 있는 프레임워크로 Starlette 프레임워크를 기반으로 하여 웹 서버에 대한 처리가 매우 빠르고 효율적이다. 따라서 스케일이 크거나 높은 트래픽을 처리해야 하는 어플리케이션에 적합하며, 비동기 처리와 타입힌트, 자동 문서화 등을 지원하여 개발자가 보다 더 구현에만 집중할 수 있도록 도와준다.

해당 프레임워크를 이제 막 한 달정도 사용해본 경험으로 가장 편리한 점은 자동 문서화, 타입 힌트로 타입 명시화, 확장성과 모듈성이다. Python 언어의 특성상 자유도가 높아 동적으로 타입이 결정되는데 이는 개발자 입장에서, 프로그램 입장에서 오류의 원인이 되기도 한다. 정적 타입의 중요성은 자바 언어를 사용하면서 깨달았었는데, FastAPI에서는 타입 힌트를 지원하고 있기 때문에 정적 타입으로 변수의 타입을 지정할 수 있다. 특히 FastAPI를 사용하면서 라우터와 모델을 정의하는 것만으로도 자동으로 Swagger API 문서를 생성해주는 것을 보고 경악을 금치 못했다. 프론트엔드와 소통할 때, 혹은 API를 테스트 해볼 때 Swagger나 Postman을 사용했었는데 일일이 설정해줘야 했던 부분들이 FastAPI에서는 코드 몇 줄만으로도 구현이 되었다.

로그인에서의 JWT

우리가 간단하게 생각하는 로그인 기능에는 사실 개발자들의 세심한 고민들이 들어가 있다. 복호화가 불가능한 비밀번호 설정부터 어떤 해싱 알고리즘을 사용할 것인지, Stateless한 HTTP에서 사용자의 인증/인가의 방법은 어떤 방식을 적용할 것인지 등에 대한 고민이 들어가 있다.

나는 JWT를 통한 인증/인가 방식을 적용하기로 했다. JWT란 Json Web Token으로 Json 형식의 토큰에 대한 표준 규격이다. 인증과 인가 정보를 서버와 클라이언트 간에 안전하게 주고 받기 위해서 사용된다. Authorization HTTP 헤더를 토큰의 형태로 설정하여 클라이언트에서 서버로 전송되며, 서버에서는 토큰에 포함되어 있는 서명 정보를 통해서 위변조 여부를 빠르게 검증할 수 있다.

하지만 위 방식에는 단점이 존재하는데, 만약 해커가 JWT(토큰)을 탈취하게 되면 사용자의 인증/인가 정보를 그대로 이용할 수 있다는 점이다. 즉, 토큰의 유효기간 동안 해커에게 속수무책으로 당해야 한다. 그렇다고 JWT(토큰)의 유효기간을 짧게 설정한다면 사용자는 서비스를 이용하다가 갑자기 로그아웃을 당하게 되는 불편함이 발생한다. 이를 해결하고자 탄생한 방법이 AccessToken과 RefreshToken이다.

AccessToken과 RefreshToken

결국 사용자의 보안성과 편리성을 높이기 위해 AccessToken과 RefreshToken을 이용해야겠다고 생각했다. AccessToken의 유효기간은 짧게 가져가고, RefreshToken의 유효기간은 길게 설정하는 전략이다. AccessToken과 RefreshToken의 원리는 다음과 같다.

  1. 로그인을 하면 사용자의 AccessToken과 RefreshToken을 모두 발급한다.
  2. 사용자가 인증이 필요한 API에 접근하고자 하면 가장 먼저 토큰을 검사한다.

토큰을 검증하는 2번의 과정에서 3가지 case가 발생한다.

case1: AccessToken과 RefreshToken 모두가 만료된 경우 -> 이 경우에는 재 로그인을 해야한다.

case2: AccessToken은 만료됐지만, RefreshToken은 유효한 경우 -> RefreshToken을 검증하여 AccessToken을 재발급

case3: AceessToken과 RefreshToken 모두가 유효한 경우 -> 정상 처리

1번 케이스와 3번 케이스의 경우에는 어렵지 않지만 2번의 경우 어떻게 처리할 것인가에 대한 고민이 있었다. 시퀀스 다이어그램을 통해 추상적인 그림을 문서로 그려보았다. 확실히 직접 그려봐야 이해가 잘됨

직접 그리느라 땀 좀 흘렸다

  1. 사용자가 ID, PW를 통해 로그인한다.
  2. 서버에서는 DB에서 값을 조회한다.
  3. 값이 유효하다면 AccessToken과 RefreshToken을 발급한다.
  4. 토큰을 클라이언트에 보내준다.
  5. 클라이언트가 데이터를 요청할 때 토큰을 함께 실어서 보낸다.
  6. 서버에서 AccessToken을 검증한다.
  7. 유효한 경우 요청한 데이터를 반환한다.
  8. 토큰의 유효시간이 지나 AccessToken이 만료됨
  9. 동일하게 서버에게 데이터를 요청한다.
  10. 서버에서는 엑세스토큰이 만료되었음을 확인한다.
  11. 클라이언트는 엑세스 토큰이 만료되었다는 내용을 전달받는다.
  12. 서버에게 AccessToken 발급을 요청한다.
  13. 서버에서는 DB에 저장된 (유효기간이 긴)RefreshToken을 확인한다. RefreshToken이 유효하다면 AccessToken을 발급한다.
  14. 발급된 토큰을 클라이언트에게 보낸다.
  15. RefreshToken마저 만료되었다면 다시 로그인하게 끔 유도한다.

이 과정을 코드로 구현하면서 '서버에서 토큰이 만료되었음을 확인한다.' 라는 부분에 의문이 있었다. 인터넷에 있는 소스들을 보아도 토큰이 만료되었음을 확인하는 부분이 안보였기 때문이다.

def create_access_token(data: dict) -> str:
    to_encode = data.copy()
    expires_delta = datetime.utcnow() + ACCESS_TOKEN_EXPIRE_MINUTES

    to_encode.update({"exp": expires_delta})
    encoded_jwt = jwt.encode(
        to_encode,
        SECRET_KEY,
        algorithm=ALGORITHM
    )
    return encoded_jwt


def create_refresh_token(data: dict) -> str:
    to_encode = data.copy()
    expires_delta = datetime.utcnow() + REFRESH_TOKEN_EXPIRE_MINUTES

    to_encode.update({"exp": expires_delta})
    encoded_jwt = jwt.encode(
        to_encode,
        SECRET_KEY,
        algorithm=ALGORITHM
    )
    return encoded_jwt

해당 부분이 AccessToken과 RefreshToken을 생성하는 코드이다. 토큰을 생성하면서 유효기간을 페이로드쪽에 삽입하면서 encode를 하게 된다. 토큰 자체에 유효기간이 들어있다는 점에 대해서 깊이 있게 생각하지 못했다. 결국 '서버에서 토큰이 만료되었음을 확인한다' 라는 과정은 Token을 decode하는 과정에서 확인이 된다.

@login_router.post(
    path="/access",
    summary="엑세스 토큰 생성",
)
async def access_token(
        session: AsyncSession = Depends(deps.get_session),
):
    """
    access_token 만료시 refresh_token 값으로 access_token을 생성한다.
   """
    try:
        # 1. refresh_token 체크
        account = await Account.get_account_by_company_id_and_branch_id(
            session=session
        )
        refresh_payload = jwt.decode(
            account.refresh_token,
            SECRET_KEY,
            algorithms=[ALGORITHM]
        )
        is_valid: str = refresh_payload.get("company_id")  # 만료되지 않았다면 값이 존재

        # 2. refresh_token이 유효하면 엑세스토큰 생성
        if is_valid:
            access_token = create_access_token(
                data={
                    "페이로드에 삽입할 값"
                }
            )

            return access_token

    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token has expired"
        )

    except jwt.JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token"
        )

 

JWT를 decode하는 과정에서 토큰의 만료시간을 검증하는 로직이 들어있었다. 토큰이 만료되었다면 페이로드에서 값을 불러올 수 없다. is_valid에 값이 존재한다면 DB에 저장된 RefreshToken의 유효기간이 아직 지나지 않았다는 뜻이니 AccessToken을 발급해서 보내주면 된다.

AccessToken과 RefreshToken을 이용하면 AccessToken이 만료되더라도 DB에 저장된 RefreshToken값을 검증하고 유효하면 AccessToken을 새로 발급해주면 되니 사용자 입장에서는 재로그인 할 필요도 없고, 토큰이 탈취되더라도 짧은 기간동안에만 위험에 노출(?)된다는 보안 장점이 존재한다. 작년에 카카오 소셜 로그인 기능을 구현하면서 AccessToken과 RefreshToken을 이용해봤었는데, 그때에는 로직이 어떻게 돌아가는지에 대해 깊이 있는 고민을 해보지 못했다. 이번에 로그인 기능을 구현하면서 짧은 기간동안 JWT와 AccessToken, RefreshToken을 직접 사용해보면서 전체적인 로직이 이해가 되었다. 자사 서비스를 운영하는 스타트업의 가장 큰 장점이라고 생각하며, 이 과정을 블로그에 꼭 남기고 싶다는 생각이 들었다.

728x90
반응형