[CEOS] 4주차 미션: DRF2 - Simple JWT & Permission

HAEN·2023년 5월 6일
0

이번 주 미션은 로그인 인증에 대해서 알아보고 Django에서 지원하는 Simple JWT로 직접 로그인을 구현해보는 것이다!


1. 로그인 인증은 어떻게 하나요?

(1) 세션과 쿠키를 이용한 인증

인증 방법

  • 사용자가 로그인을 하면 서버에서 사용자를 확인한 후, 사용자에게 고유한 ID값을 부여하여 세션 저장소에 저장하고 Session ID를 발급한다

  • 서버는 HTTP response header에 발급된 Session ID를 넣어 보낸다
    클라이언트는 매 요청마다 HTTP request header에 Session ID가 담킨 쿠키를 보낸다

  • 서버에서는 세션 저장소에서 받은 쿠키와 일치하는 정보를 가져와 인증을 한다

장점

  • 사용자의 정보는 세션 저장소에 저장되기 때문에 쿠키가 노출되더라도 사용자의 정보는 알 수 없다

  • 사용자는 고유의 Session ID를 가지므로 회원 정보를 하나씩 대조할 필요가 없어 서버 자원 낭비를 줄일 수 있다

단점

  • 노출된 쿠키를 사용하여 서버로 HTTP 요청을 보내 서버가 사용자로 오인해 정보를 전달하게 만드는 세션 하이재킹 공격에 취약하다

  • 세션 저장소를 사용하기 때문에 추가적인 저장공간을 필요로 한다


(2) Access Token을 이용한 인증

인증 방법

  • 사용자가 로그인을 하면 서버에서 사용자를 확인 후, Payload에 사용자의 정보를 넣는다

  • 암호화된 Access Token을 HTTP response header에 넣어 보낸다

  • 사용자는 Access Token을 받아 저장한 후, 인증이 필요한 요청마다 토큰을 HTTP request header에 넣어 보낸다

  • 서버에서는 해당 토큰의 Verify Signature를 복호화한 후, 조작 여부와 토큰 유효 기간을 확인한다

  • 검증이 완료된다면, Payload를 디코딩하여 사용자의 정보에 맞는 데이터를 가져온다

장점

  • 유저 정보를 토큰에 저장하므로 서버에 따로 추가 저장 공간이 필요 없다 -> 메모리, 비용 절감

  • 토큰 기반으로 하는 다른 인증 시스템에 접근이 가능하기 때문에 확장성이 뛰어나다

단점

  • JWT는 한 번 발급되면 유효기간이 만료될 때까지 삭제가 불가능하므로 한번 노출되면 대처할 방안이 없다

  • Payload는 디코딩하면 누구나 접근할 수 있기에 중요한 정보들을 보관할 수 없다

  • JWT는 길기 때문에 인증 요청이 많아지면 서버에 자원낭비가 발생한다


(3) Access Token + Refresh Token을 이용한 인증

Access Token을 이용한 인증 방식의 문제는 토큰이 노출될시 대처 방안이 없다는 것이다
토큰의 유효기간을 짧게 하면 사용자는 로그인을 자주 해야해서 번거롭기에 이를 해결하고자 나온 것이 Refresh Token이다

Refresh TokenAccess Token과 같은 형태인 JWT이다
Refresh TokenAccess Token보다 유효기간이 길며, Access Token이 만료됐을 때 Refresh Token을 통해 Access Token을 재발급 할 수 있다

인증 방법

  • 사용자가 로그인을 하면 Access Token, Refresh Token을 발급하여 HTTP response header에 넣어 보낸다 Refresh Token은 따로 사용자 DB에 저장해놓는다

  • 사용자는 Refresh Token은 안전한 저장소에 저장 후, 요청을 보낼 때 Access Token을 HTTP request header에 넣어 보낸다

  • 서버는 받은 Access Token을 검증한다 이때 유효기간이 지나 Access Token이 만료됐다면 권한 없음을 HTTP 응답으로 보낸다

  • 권한 없음 응답을 받은 사용자는 Refresh TokenAccess Token을 함께 HTTP request header에 보낸다

  • 서버는 받은 Access Token을 검증한 후, 받은 Refresh Token과 사용자의 DB에 저장되어 있던 Refresh Token을 비교한다

  • Refresh Token이 동일하고 유효기간도 지나지 않았다면 새로운 Access Token을 발급하여 HTTP response header로 보낸다

장점

  • Access Token의 유효 기간이 짧기 때문에, 기존의 Access Token만을 이용한 인증보다 안전하다

단점

  • Access Token만 사용하는 것 보다 구현이 복잡하다

  • Access Token이 만료될 때마다 새롭게 발급하는 과정에서 서버의 자원 낭비가 생긴다


(4) OAuth를 이용한 인증

OAuth는 외부서비스의 인증 및 권한부여를 관리하는 범용적인 프로토콜이다
현재 범용적으로 사용되고 있는 것은 OAuth 2.0이다



2. JWT

(1) JWT란?

JSON Web Token(JWT)는 인증에 필요한 정보들을 암호화시킨 토큰으로, 당사자 간에 정보를 JSON 형태로 안전하게 전송할 수 있다
이는 Access Token으로 사용된다


(2) JWT의 구성

JWT를 생성하기 위해서는 Header, Payload, Verify Signature 객체가 필요하다


<Header>
Headeralgtyp로 구성된다

  • alg: 암호화 방식(해싱 알고리즘)
  • typ: 토큰의 타입
{
  'alg': 'HS256',
  'typ': 'JWT'
}

<Payload>
Payload는 토큰에 담을 정보를 나타낸다
하나의 정보 조각을 클레임(Claims)으로 부르는데, 클레임의 종류로는 Registered, Public, Private로 3가지가 존재한다

  • Registered Claims(필수는 아닌 이름이 지정되어 있는 클레임들):

    • iss: JWT의 발급자 주체, 대소문자를 구분하는 문자열
    • sub: JWT의 제목
    • aud: JWT의 수신인, JWT를 처리하려는 주체는 해당 값으로 자신을 식별해야함(요청 처리의 주체가 aud 값으로 자신을 식별하지 않으면 JWT는 거부됨)
    • exp: JWT의 만료시간 설정(NumericDate 형식)
    • nbf: Not Before을 의미
    • iat: JWT가 발급된 시간
    • jti: JWT의 식별자 값 (JWT ID), 중복 처리를 방지하기 위해 사용
  • Public Claims: 키와 값을 마음대로 정의 가능(충돌이 발생하지 않을 이름으로 설정해야함)

  • Private Claims: 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임

{
  'sub': 'ceos payload',
  'name': 'haensu',
  'admin': true,
  'iat': 1516239022
}

<Verify Signature>
Payload가 위변조되지 않았다는 사실을 증명하는 문자열이다
Base64Url 방식으로 인코딩한 Header, Payload 그리고 secret key를 더한 후 암호화된다


완성된 토큰은 <Header\>.<Payload\>.<Signature\>의 형식을 가진다
HeaderPayload는 인코딩될 뿐, 따로 암호화되지 않기에 누구나 디코딩하여 확인할 수 있기에 정보가 쉽게 노출될 수 있다
하지만 Verify Signature는 secret key를 알지 못하면 복호화할 수 없다

만약에 해커가 사용자의 토큰을 훔쳐 Payload의 데이터를 변경하여 토큰을 서버로 보낸다면, 서버에서 Verify Signature를 검사하여 토큰의 유효성을 판단한다



3. Django Simple JWT

이제 직접 JWT를 이용한 인증을 구현해보겠다!
Django에서는 JWT 인증을 위해 Simple JWT라는 라이브러리를 제공한다

(1) Simple JWT 설치 및 설정

pip install djangorestframework-simplejwt

위 명령어를 통해 라이브러리를 설치해주고

INSTALLED_APPS = [
    ...
    'rest_framework_simplejwt',
	...
]

...

REST_FRAMEWORK = {
    ...
    'DEFAULT_AUTHENTICATION_CLASSES': (
        ...
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
    ...
}

...

# 추가적인 JWT_AUTH 설정
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': False,
    'BLACKLIST_AFTER_ROTATION': False,
    'UPDATE_LAST_LOGIN': False,

    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
    'VERIFYING_KEY': None,
    'AUDIENCE': None,
    'ISSUER': None,
    'JWK_URL': None,
    'LEEWAY': 0,

    'AUTH_HEADER_TYPES': ('Bearer',),
    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',

    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',
    'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',

    'JTI_CLAIM': 'jti',

    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

AUTH_USER_MODEL = 'accounts.User'

setting.py에 다음과 같이 추가한다
SIMPLE_JWT에서는 토큰의 유효시간 등을 설정할 수 있다
freeze 명령어로 requirements.txt에 라이브러리 추가하는 거 잊지 말자!


(2) User Model 커스텀

JWT 인증을 사용하기 위해서는 AbstractBaseUser 모델을 상속하여 기본 유저 모델을 만들어야 한다
이전에는 AbstractUser 모델을 상속했었기에
DB랑 마이그레이션 모두 날리고 다시 만들었다 ㅠ

class UserManager(BaseUserManager):
    def create_user(self, username, email, nickname, password=None):
        if not username:
            raise ValueError(_('Users must have an ID'))

        if not email:
            raise ValueError(_('Users must have an email address'))

        user = self.model(
            username=username,
            email=self.normalize_email(email),
            nickname=nickname
        )

        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, username, email, nickname, password):
        user = self.create_user(
            username=username,
            email=email,
            password=password,
            nickname=nickname,
        )

        user.is_superuser = True
        user.save(using=self._db)
        return user


class User(AbstractBaseUser, BaseTimeModel, PermissionsMixin):
    username = models.CharField(max_length=15, unique=True)
    school = models.ForeignKey(School, on_delete=models.CASCADE, related_name='users', null=True)
    nickname = models.CharField(max_length=10, unique=True)
    email = models.EmailField(max_length=255, unique=True)
    name = models.CharField(max_length=10)

    objects = UserManager()

    USERNAME_FIELD = 'username'
    REQUIRED_FIELDS = ['nickname', 'email']

    @property
    def is_staff(self):
        return self.is_superuser

    def __str__(self):
        return self.username

(3) 회원가입/로그인/로그아웃 구현

회원 가입 후 로그인을 성공하면 Access Token과 Refresh Token을 모두 발급해주는 로직으로 구현하였다


회원가입시 password가 암호화되어 DB에 저장된다

class SignIn(APIView):
    permission_classes = [AllowAny]

    def post(self, request):
        username = request.data['username']
        password = request.data['password']

        user = User.objects.filter(username=username).first()

        # ID가 존재하지 않는 경우
        if user is None:
            return Response(
                {"message": "회원정보가 일치하지 않습니다"}, status=status.HTTP_400_BAD_REQUEST
            )

        # # 비밀번호가 틀린 경우
        if not check_password(password, user.password):
            return Response(
                {"message": "비밀번호가 틀렸습니다."}, status=status.HTTP_400_BAD_REQUEST
            )

        # user가 맞다면,
        if user is not None:
            token = TokenObtainPairSerializer.get_token(user)  # refresh 토큰 생성
            refresh_token = str(token)  # refresh 토큰 문자열화
            access_token = str(token.access_token)  # access 토큰 문자열화
            response = Response(
                {
                    "user": UserSerializer(user).data,
                    "message": "login success",
                    "jwt_token": {
                        "access_token": access_token,
                        "refresh_token": refresh_token
                    },
                },
                status=status.HTTP_200_OK
            )

            response.set_cookie("access_token", access_token, httponly=True)
            response.set_cookie("refresh_token", refresh_token, httponly=True)
            return response
        else:
            return Response(
                {"message": "로그인에 실패하였습니다."}, status=status.HTTP_400_BAD_REQUEST
            )
  • TokenObtainPairSerializer.get_token(user)은 Simple JWT의 내장 Serializer이다 이를 통해 Refresh Token을 생성한다

  • check_password(current_password,user.password)를 통해 hash 암호화 되어 저장된 password와 클라이언트로부터 받은 password를 비교하여 boolean값을 return해준다

  • response.set_cookie()'를 통해 Access TokenRefresh Token을 쿠키에 저장한다

로그인 성공시 Access TokenRefresh Token이 쿠키에 저장되는 것을 볼 수 있다

로그아웃시 쿠키에서 token이 삭제된다


(4) Access Token 재발급

simple-jwt 라이브러리에서 Refresh Token으로 Access Token을 재발급 해주는 뷰를 제공해준다

from django.urls import path, include
from rest_framework_simplejwt.views import TokenRefreshView

from accounts.views import SignUp, SignIn, Logout

urlpatterns = [
    path("sign-up/", SignUp.as_view()),
    path("sign-in/", SignIn.as_view()),
    path("log-out/", Logout.as_view()),
    path("refresh/", TokenRefreshView.as_view())
]

위와 같이 제대로 발급되는 것을 볼 수 있다!


4. Permission

이제 권한 설정을 구현해보겠다
에브리타임을 이용하기 위해서는 로그인이 필수이다
즉, 로그인 하지 않은 사용자가 회원가입, 로그인 이외의 url에 접근하는 것을 막아야한다

로그인 했다는 것을 판별하기 위해 HTTP request header에 Access Token을 함께 보낸다
Access Token이 존재하지 않으면 해당 url에 접근할 수 없다
그럼 구현해보자!

pip install dj-rest-auth

우선 위의 명령어를 통해 라이브러리를 설치해준다

INSTALLED_APPS = [
	...
    'dj_rest_auth',
    'rest_framework.authtoken',
	...
]

REST_FRAMEWORK = {
    # 'DEFAULT_AUTHENTICATION_CLASSES': (
    #     'rest_framework_simplejwt.authentication.JWTAuthentication',
    # )
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'dj_rest_auth.jwt_auth.JWTCookieAuthentication'
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ]
}

DEFAULT_PERMISSION_CLASSESIsAuthenticated로 설정했다

그리고 토큰 인증이 필요없는 회원가입, 로그인 뷰에는

permission_classes = [AllowAny]

접근 권한을 AllowAny로 설정해주었다

Postman에서 새로운 environments를 추가했고

로그인 API 테스트에서 로그인시 발급받은 Access Token이 자동으로 환경변수에 등록되도록 했다

마지막으로 Authorization에서 Bearer Token을 환경 변수로 설정해주면 끝!

로그인 하지 않았을 때 접근할 수 없다

로그인 하면 이렇게 접근 허용~~


지금까지 simple-jwt를 이용한 인증 및 permission을 구현해보았다!



profile
핸수

0개의 댓글