나는 이렇게 학습한다/CS

로그인시 Access Token, Refresh Token 보내주기

daco2020 2022. 4. 13. 22:38
반응형

액세스 토큰을 사용하는 이유

 

서버가 액세스 토큰을 클라이언트에게 주면 클라이언트는 매 요청시 액세스 토큰을 서버로 보내주어 로그인 상태을 알려줍니다.

 

이러한 방식은 HTTP의 무상태 특성을 보완하기 위한 한 가지 방법이지만 액세스 토큰을 주는 방식은 전달 과정에서 탈취 당할 우려가 있어 보안에 문제가 있습니다.

 

 

이를 해결하기 위해 토큰에 만료기간을 주어, 만약 탈취를 당하더라도 시간이 지나면 토큰을 사용할 수 없게 만들 수 있습니다.

 

하지만 이는 로그인 상태가 주기적으로 풀린다는 뜻이고 사용자에게 큰 불편을 줄 것입니다.

그래서 사람들은 리프레시 토큰을 생각해내었습니다.

 

 

리프레시 토큰으로 액세스 토큰 재발급

리프레시 토큰은 액세스 토큰이 만료되었을 경우 이를 확인하고 다시 액세스 토큰을 발급하는 방법입니다.

 

사용자는 재로그인을 하지 않고도, 본인이 인지하지 못하는 사이에 액세스 토큰을 재발급, 계속해서 로그인 상태를 유지하는 것입니다.

 

리프레시 토큰을 구현하는 방법은 여러가지이고 각 서비스의 특성에 따라 달라질 수 있습니다. 예를 들어 액세스 토큰이 만료되면 이를 확인하고 다시 액세스 토큰을 재발급하는 경우, 또는 처음 로그인시 액세스 토큰과 리프레시 토큰을 함께 전달하여 액세스 토큰이 만료되면 리프레시 토큰을 보내 액세스 토큰을 재발급하는 경우가 있습니다.

 

 

오늘은 코드를 통해 리프레시 토큰을 어떻게 생성하고 클라이언트에게 보내는지 확인해보겠습니다.

아래 작성한 코드는 처음 로그인시 액세스토큰과 리프레시토큰을 함께 응답하는 로직입니다.

 

 

API 코드

 

먼저 API 코드를 보겠습니다.

 

 

Python - FastAPI 로 작성한 코드입니다.

# API code 작성
--------------------------------------------------
@router.post("/login")
def login(user_in: UserLogin, db: Session = Depends(get_db)):
    user_obj = user.get_user_by_email(user_in.email, db)
    if not user_obj:
        raise HTTPException(status_code=401, detail="Invalid email")
    if user_obj.password != user_in.password:
        raise HTTPException(status_code=401, detail="Invalid password")

    access_token = auth.create_access_token(user_obj)
    refresh_token = auth.create_refresh_token(user_obj)

    return {"access_token": access_token, "refresh_token": refresh_token}

로그인을 요청할때, user_in(유저정보) ,db(db와 연결할때 사용)를 인자로 받습니다.

 

그리고 입력된 유저정보를 통해 user가 실제 존재하고 password가 일치하는지 확인합니다.

(이 글은 토큰 로직에 대한 설명이므로 코드에 대해서 자세히 설명하지 않겠습니다)

 

확인이 되었다면 create_access_token, create_refresh_token 메서드를 호출하여 토큰을 생성합니다.

 

생성된 토큰을 딕셔너리 형태로 반환합니다.

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2NDk4NjE3OTIsImlhdCI6MTY0OTg1NDU5Miwic3ViIjoxfQ.wFEyRmhOFX6tilf21jRVxWtn_LoIlGly4IDcOSUl9rI",
  "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2NTEwNjQxOTIsImlhdCI6MTY0OTg1NDU5Miwic3ViIjoiMS5yZWZyZXNoIn0.94Tj2_jnyhCtFzFFaY-lYoNUiN0lZYCVm1TGh2znfNo"
}

요청을 보내니 정상적으로 응답하는 것을 볼 수 있습니다.

 

 

토큰 구현 코드

 

토큰을 만들기 위해 jwt 라이브러리를 사용했습니다.

Auth와 관련된 메서드를 class로 묶어 객체로 사용하였습니다.

 

 

코드는 다음과 같습니다.

# Auth와 관련된 class 작성
--------------------------------------------------
class AuthHandler:
    secret = settings.SECRET_KEY
    algorithm = settings.ALGORITHM
    access_expires = timedelta(hours=2)
    refresh_expires = timedelta(days=14)

    def encode_token(self, sub: Union[int, str], expires: timedelta):
        payload = {
            "exp": datetime.utcnow() + expires,
            "iat": datetime.utcnow(),
            "sub": sub,
        }

        return jwt.encode(payload, self.secret, self.algorithm)

    def decode_token(self, token: str):
        try:
            payload = jwt.decode(token, self.secret, algorithms=[self.algorithm])
            return payload["sub"]
        except jwt.ExpiredSignatureError:
            raise HTTPException(status_code=401, detail="Token expired")
        except jwt.InvalidTokenError:
            raise HTTPException(status_code=401, detail="Invalid token")

    def create_access_token(self, user_obj):
        """
        액세스 토큰에는 유저 id(pk)만을 sub에 담습니다.
        이후 sub는 decode를 통해 인증인가에 사용됩니다.
        """
        sub = user_obj.id
        return self.encode_token(sub, self.access_expires)

    def create_refresh_token(self, user_obj):
        """
        리프레시 토큰으로 인증인가를 할 수 없도록 액세스 토큰과 다른 sub내용을 만듭니다.
        sub : 유저 id(pk) + 'refresh'
        """
        sub = f"{user_obj.id}.refresh"
        return self.encode_token(sub, self.refresh_expires)

auth = AuthHandler()

 

encode_token

encode_token 메서드를 보면 페이로드에 기간에 대한 정보들과 sub가 담기는 것을 볼 수 있습니다.

exp는 만료시간, iat는 발급시간, sub는 유저에 대한 정보를 가지고 있습니다.

 

이를 딕셔너리 형태로 묶고 미리 환경변수로 설정해둔 시크릿키와 알고리즘으로 이들을 토큰으로 만듭니다.

 

그렇게 만들어진 토큰은 앞서 본것처럼 이런 형태를 가집니다.

"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2NDk4NjE3OTIsImlhdCI6MTY0OTg1NDU5Miwic3ViIjoxfQ.wFEyRmhOFX6tilf21jRVxWtn_LoIlGly4IDcOSUl9rI"

 

decode_token

decode_token 메서드는 토큰을 다시 복호화하여 원래의 페이로드를 복원한다고 볼 수 있습니다.

 

페이로드에는 전에 담겼던 정보들이 담겨 있습니다.

 

이 과정에서 문제가 있다면 예외처리를 통해 에러 메시지를 반환합니다.

jwt.ExpiredSignatureError는 만료시간이 지났는지 여부를 잡아냅니다.

jwt.InvalidTokenError는 토큰 자체가 유효한 토큰인지 확인합니다.

 

 

액세스 토큰과 리프레시 토큰의 차이점

이 로직에서 액세스 토큰과 리프레시 토큰은 두 개 차이점을 가지고 있습니다.

 

첫번째는 만료기간이 다릅니다.

리프레시 토큰은 상대적으로 만료시간이 깁니다.

그 이유는 액세스 토큰이 만료되었을때, 리프레시 토큰으로 액세스 토큰을 재발급 하기 때문입니다.

 

이 로직에서는 객체내 변수를 설정하여 액세스 토큰은 2시간, 리프레시 토큰은 2주의 만료기간을 가지도록 하였습니다.

access_expires = timedelta(hours=2)
refresh_expires = timedelta(days=14)

 

두 번째 차이는 페이로드에 담긴 sub의 내용입니다.

만약 액세스 토큰으로도 액세스 토큰을 재발급하고, 리프레시 토큰으로도 로그인 상태를 확인할 수 있도록 한다면 서로의 역할이 혼동될 수 있습니다.

 

특히나 액세스 토큰을 탈취해서 계속해서 액세스 토큰을 재발급 받을 수 있다면 보안상 문제가 있을 수 있겠죠?

 

때문에 이 둘이 서로의 역할을 침범하지 않도록 페이로드에 각기 다른 정보를 담았습니다.

이렇게 하면 api는 페이로드의 내용을 보고 이것이 액세스 토큰인지 리프레시 토큰인지 알 수 있습니다.

 

이 로직에서는 유저의 id에 ‘refresh’ 라는 문자열을 붙이는 식으로 리프레시 토큰의 sub 정보를 만들었습니다.

 

 

마무리

토큰 로직을 구현하는데 정답은 없다고 생각합니다.

원하는 방식에 따라 완전히 다른 방식으로 구현할 수도 있습니다.

 

예를들어 리프레시 토큰을 서버에 저장하고 이를 확인하는 로직을 구현할수도 있습니다.

페이로드에 다른 정보를 담아 다른방식으로 정보를 확인할수도 있습니다.

토큰을 2개가 아닌 그 이상으로 사용할수도 있습니다.

 

 

문제를 해결하는 방법은 다양할거라 생각합니다.

 

여기에 작성된 로직 또한 제 주관으로 만든 코드이며 참고만 하시길 바랍니다!

 

 

추가로 읽으면 좋은 글

 

Refrsh token을 구현하며 느낀 점

리프레시 토큰을 사용하는 이유 리프레시 토큰은 액세스 토큰을 재발급하기 위한 용도로 사용합니다. 간략하게 토큰 로그인 과정을 설명해보겠습니다. 유저의 로그인 상태를 유지하기 위해서

daco2020.tistory.com

 

반응형