티스토리 뷰

신규 API 서버에서는 Oauth 방식의 SNS로그인과 JWT를 사용한 토큰 기반의 인증/인가 방식을 구현하였다.

토큰 기반의 인증 방식을 사용한 이유는 다음과 같다.

  • 기존 API 서버에도 동일한 인증 방식을 쉽게 적용할 수 있다.
  • 서버에서 디비 조회 없이도 검증이 가능하다.

개념 설명은 다른 포스팅에서 다루도록 하고 구현에 집중하여 살펴보겠다.

스펙

  • Python 3.12
  • FastAPI 0.103.1

구현

Access Token 사용

  • 유저의 인가(Authorization)를 검증할 때 access token이 유효한지로 판단한다.
  • access token에는 서비스에 필요한 유저 정보와 만료일자 등이 포함되며 해당 포스팅에서는 다음과 같은 데이터를 포함한다.
{
	user_id: int,
	user_device_id: int,
	exp: int
}
  • 인가가 필요한 API를 요청할 때는 access token을 Authorization header에 포함하여 요청한다.

Refresh Token 사용

  • access token이 만료되면 유저는 재로그인을 해서 access token을 새로 발급받아야 하는데, 잦은 재로그인은 유저 경험에 좋지 않다.
  • 이에 대한 해결법으로 refresh token을 사용해 재로그인 없이 access token을 갱신한다.
  • 유저가 유효한 refresh token을 갖고 있으면 access token을 새로 발급하고 없으면 로그아웃을 유도한다.

RefreshTokenRotation 방식 적용 ( RTR )

  • RTR 방식은 리프레시 토큰을 한 번만 사용하기 위한 방법으로, Refresh token을 사용하여 새로운 access token을 발급받을 때 refresh token도 새로 발급받는다.
  • refresh token으로는 새 access token을 발급받을 수 있기 때문에 탈취 시 큰 문제가 된다.
  • 이런 문제를 방지하기 위해 token 갱신 요청 시, refresh token도 함께 갱신하여 이전에 사용한 refresh token은 유효하지 않도록 한다.
  • 구체적으로는 user table에 refresh_token을 저장하여 토큰 갱신 시 client에서 넘어온 refresh token과 user table의 refresh token을 비교하여 검증한다.

Blacklist 사용

  • access token이 유효한지 검증할 때는 2가지를 검증한다.
    • secret key를 사용하여 디코딩 할 수 있는지.
    • access token의 만료일자가 지나지 않았는지.
  • 2가지 중 하나라도 통과하지 못하면 유효하지 않은 토큰이다. 반대로 이 두 가지를 만족하면 유효한 토큰이다.
  • 토큰은 한 번 생성되면 데이터나 만료일자를 변경할 수 없기 때문에 만료시간이 끝날 때까지 유효하다. 다시 말해 로그아웃을 해도 access token의 만료일자가 남아있다면 해당 access token을 사용해 인가를 통과할 수 있다.
  • 이 문제를 해결하기 위해 로그 아웃 시 만료일자가 남은 access token을 blacklist에 추가한다.
  • 유저 인가 여부를 확인할 때 blacklist에 등록된 토큰인지 확인하여 해당 토큰으로 검증해도 되는지 확인한다.

용어 정리

로그인 과정을 살펴보기에 앞서 사용된 용어들을 정리했다.

Resource Owner ( 리소스 오너 )

  • 우리의 서비스를 이용하는 실제 유저

Resource Server ( 리소스 서버 )

  • 구글, 카카오 등 리소스를 가지고 있는 서버
  • 우리는 SNS 로그인 인증까지만 진행하고 리소스를 사용하지 않으므로 리소스 서버는 로직상 사용하지 않는다.

Client ( 클라이언트 )

  • 리소스 서버의 자원을 이용하고자 하는 서비스. 보통 우리가 개발하려는 서비스에 해당한다.

Authorization Server ( 인증 서버 )

  • 리소스 오너를 인증하고 클라이언트에게 Access Token을 발급해주는 서버

위 용어에서 클라이언트가 우리가 개발하는 서비스에 해당하고, 해당 포스팅에서는 이 클라이언트를

client-front와 client-server로 세분화하여 설명하겠다.

로그인 구현

이제 로그인 구현 방식을 살펴보자. 우리 서비스가 카카오 간편 로그인하기를 사용해 로그인 처리를 진행한다고 가정한다.

1. SNS 로그인

  • 우리 서비스에서 카카오 간편 로그인 이동하기를 누르면, 카카오 로그인 페이지로 리다이렉트 된다.
  • 로그인이 성공하면 카카오에서 authorization_code와 함께 callback을 호출한다.
  • 클라이언트는 authorization_code를 사용해 카카오 access_token을 발급받는다.
참고
해당 포스팅에서는 기존 API 서버와 통일하기 위해 client-front에서 access token을 발급받지만 server에서 발급받기도 한다.

 

2. Client 로그인

  • 카카오에서 발급받은 access_token을 사용해 client-server에 Client 로그인을 요청한다.
  • client-server는 access_token을 사용하여 카카오 profile을 조회하고 성공한다면 인증된 유저로 판단한다.
requests.get("https://kapi.kakao.com/v2/user/me", headers={"Authorization": f"Bearer {access_token}"})
  • 클라이언트 자체 access_token과 refresh_token을 각각 생성한다. 아래는 access_token 생성 예시이다.
payload = TokenSchema(user_id=user_id, user_device_id=user_device_id, exp=datetime.now() + timedelta(hours=TokenManager.ACCESS_TOKEN_EXPIRE_HOURS)).model_dump()
access_token = jwt_client.encode(payload=payload, key=self.secret_key, algorithm=self.algorithm)
  • refresh_token은 user table or redis에 저장한다. 이 데이터는 RTR방식으로 적용하여 교체된 refresh_token이 유효한지 검증하는 데 사용된다.
  • access_token을 응답값으로 내려준다. 클라이언트는 access_token을 local storage에 저장한다.
200 ok
{
	"access_token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
}
  • 여기까지 진행하면 로그인이 완료됐다. 이제 유저는 access token의 만료일자가 도래할 때까지 유효한 권한을 갖는다.

 

3. Authorization을 포함한 API 요청

  • access_token을 응답받으면 client-front는 API 요청 시 header에 Autorization를 포함해 요청한다.
curl -X 'POST' \
  'https://dev-api.co.kr/v1/coupons/download' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' \
  -H 'Content-Type: application/json'
  • 서버에서는 2가지를 확인해 인가를 검증한다.
    • 토큰의 secret_key 디코딩과 exp를 확인했을 때 유효한가?
    • 토큰이 blacklist에 포함되지 않았는가?

Access Token 재발급

Access Token이 만료되면 재발급을 통해 인가를 연장할 수 있다.

API 요청 전에 client-front에서 token의 exp 만료 여부를 확인한 후 만료일자가 지났으면 토큰 갱신을 요청한다.

참고
client-front에서 exp 만료 여부 확인 없이 우선 서버로 API 요청을 보내고, 토큰이 invalid 하다는 응답을 받은 후에 토큰 갱신을 요청하기도 한다. 여기서는 트래픽 횟수를 줄이고자 client에서 만료 여부를 확인하도록 하였다.

 

Case-1. Access Token의 만료 일자가 지난 경우

  • client-front에서 access token의 만료 여부를 검증했을 때 만료 일자가 지났다면 client-server로 토큰 갱신을 요청한다.
  • 토큰 갱신 요청 시 Cookie에 refresh_token을 포함한다.
  • client-server에서는 access token을 재발급할 수 있는, 유효한 refresh_token인지 검증한다. 검증 방식은 다음과 같다.
    • decode 가능한 refresh token인지 검증.
    • user table에 저장된 refresh token과 일치하는지 확인.

Case-1-1. Refresh Token이 유효한 경우

  • access token을 재발급한다.
  • refresh token도 재발급하고 user table에 refresh token 정보를 업데이트한다.
  • 응답 값으로 body에 access_token과 cookie에 refresh_token을 전달한다.

Case-1-2. Refresh Token이 유효하지 않은 경우

  • 서버에서는 401 응답을 내려준다.
  • client-front는 access token 재발급에 실패했기 때문에 로그아웃을 시도하게 된다.

Case-2. Access Token의 만료 일자가 지나지 않은 경우

  • 유효한 토큰이므로, 유저가 요청한 API header에 access token을 실어 요청한다.

로그아웃 구현

마지막으로 로그아웃 구현을 살펴보자.

  • 로그아웃 요청 시 user table의 refresh token을 삭제 처리한다.
  • access token의 만료일자가 지나지 않았다면 blacklist에 추가한다.
  • redis에 access token의 만료일자까지의 ttl을 설정하여, 만료시간이 되면 blacklist에서 사라지게 하면 된다. 아래는 예시 코드이다.
decoded_token = self.decode(token=token)
now = datetime.now().timestamp()
exp = decoded_token.exp.timestamp()
ttl = exp - now
redis.setex(token, ttl, "blacklisted")
  • user table에 refresh token을 삭제했기 때문에 토큰 재발급이 불가능하고, access token을 blacklist 처리했기 때문에 만료일자가 남았더라도 유효성 검증에서 실패한다.

마지막으로, 토큰 기반 로그인을 구현하며 궁금했던 점들을 정리해 본다.

  • refresh token도 client에 넘겨주고 같이 요청해야 하는지?
    • access token 갱신할 때 access token이 유효하지 않을 수 있기 때문에(날짜 지남 등) refresh token으로 유효성을 확인해야 한다.
    • 만약 요청에 유효하지 않은 access token만 넘어온다면 이 요청이 유효한지 알 수 없다.
    • 전달받은 refresh token이 유효한지 확인을 위해 db에 있는 refresh token과 일치하는지 확인도 필요하다.
  • RTR 방식을 사용하는 이유
    • access token을 갱신할 때마다 refresh token을 새로 발급하면, 기존의 refresh token이 탈취되어도 유효하지 않기 때문에 공격에 방어할 수 있다.
    • 다만, 실제 유저가 해당 서비스를 이용하지 않는 상태에서 탈취당하면 유저가 새로 로그인하기 전까지 공격자의 refresh token을 계속 유효하다고 판단한다.
  • token들은 어디에 저장해야 하는지?
    • access_token : payload로 전달. client에서 local storage로 저장해서 authorization 헤더에 담아 요청할 수 있도록 한다. cookie로 전달할 수도 있지만 httponly옵션이 붙으면 클라이언트가 Authorization 헤더에 담는 게 불가능할 것으로 보임. 그렇다면 body에 넣는 게 압축도 되고 좋다.
    • refresh_token : cookie + http_only & secure 옵션으로 전달. client에서 조작하는 것을 방지한다. 그냥 cookie로 저장하면 csrf 공격등의 위험이 있다.
  • 보안 이슈 검토
    • 은탄환은 없다.
    • xss 공격 : 해커가 클라이언트 브라우저에 js를 삽입해 실행하는 공격
      • cookie를 httponly옵션을 사용해서 쿠키 접근이 불가능하도록 막을 수 있다.
      • 그러나 local storage는 바로 탈취당할 수 있다.
      • access_token이 탈취당해도 refresh token이 없으면 갱신할 수 없음.
    • csrf 공격 : 정상적인 reqeust를 가로채 백엔드에 악의적인 동작을 수행
      • locasl storage 데이터를 조작할 수 없으므로 access_token을 취득하지 못할 거라 예상
      • 쿠키인 refresh_token이 탈취당해도 access_token이 없으면 유효하지 않음. → 근데 갱신은 할 수 있을지도?

참고

https://fusionauth.io/articles/oauth/modern-guide-to-oauth#how-oauth-20-differs-from-oauth-10

https://oauth.net/2/

https://blog.naver.com/mds_datasecurity/222182943542

https://pragmaticwebsecurity.com/articles/oauthoidc/refresh-token-protection-implications.html

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함