장고 실전 #2 인증/권한 — Token, JWT, custom permission

7 분 소요

#1에서 만든 PostViewSet은 누구나 글을 쓰고 수정할 수 있는 상태입니다. 진짜 서비스가 되려면 두 질문에 답해야 합니다.

  • 인증 (Authentication) — “당신이 누구인가?”
  • 인가 (Authorization) — “그 사람이 이 일을 해도 되는가?”

DRF는 두 범주에 각각 **인증 클래스 (authentication classes)**와 **권한 클래스 (permission classes)**를 가집니다. 둘은 완전히 분리된 개념이지만 같이 동작합니다.

DRF 인증 vs Django 본체 인증 #

중급 #4 사용자/권한의 Django 본체 인증과 DRF 인증은 성격이 다릅니다.

Django 본체 인증DRF 인증
주된 용도서버 렌더링 페이지JSON API
저장소session + cookieheader (Token / JWT)
미들웨어AuthenticationMiddlewareDEFAULT_AUTHENTICATION_CLASSES
로그인 formLoginView (template)obtain_auth_token / JWT view
request.user미들웨어가 채움인증 클래스가 채움
함께 쓸 수 있나 (SessionAuth도 DRF가 지원)

같은 User 모델을 공유하고, Django의 is_authenticated, permissions 같은 것도 그대로 씁니다. **인증의 트랜스포트(어디서 자격을 가져오는가)**만 다를 뿐.

DRF의 인증 클래스 #

DRF는 한 요청에 여러 인증 클래스를 차례로 시도 합니다. 첫 번째로 성공한 인증을 채택합니다.

settings.py — 글로벌 설정
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

기본 설정은 글로벌. View 별로 오버라이드 가능.

자주 쓰는 인증 클래스 #

클래스쓰임
SessionAuthentication같은 origin의 브라우저 (Browsable API, admin같이 쓸 때)
BasicAuthentication디버그/테스트 (프로덕션 비추)
TokenAuthentication단순 토큰. DB의 authtoken_token에 저장
JWT (simplejwt)표준 토큰. stateless, 만료/refresh
OAuth2외부 IdP, 다중 클라이언트 (django-oauth-toolkit)

작은 서비스 / 모바일 앱은 보통 **JWT (simplejwt)**가 첫 선택. 매우 단순한 내부 도구는 TokenAuthentication도 충분합니다.

TokenAuthentication으로 시작 #

가장 간단한 토큰 흐름을 정리합니다. Django의 authtoken 앱이 token 테이블을 만들고, obtain_auth_token view가 이메일/비밀번호로 토큰을 발급해 줍니다.

settings.py
INSTALLED_APPS = [
    ...,
    "rest_framework",
    "rest_framework.authtoken",   # 추가
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}
마이그레이션 + 사용자별 토큰 자동 생성 시그널
uv run python manage.py migrate
blog/signals.py — 회원가입 시 토큰 자동 생성
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from rest_framework.authtoken.models import Token


@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_auth_token(sender, instance=None, created=False, **kwargs):
    if created:
        Token.objects.create(user=instance)

중급 #3 Signals 패턴 그대로. 사용자가 생성되는 순간 토큰도 같이.

토큰 발급 view #

mysite/urls.py
from rest_framework.authtoken.views import obtain_auth_token

urlpatterns = [
    ...,
    path("api/auth/token/", obtain_auth_token),
]
요청
curl -X POST http://localhost:8000/api/auth/token/ \
  -d "username=alice&password=secret"
응답
{"token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"}

이후 모든 요청에 헤더로 토큰을 붙입니다.

인증된 요청
curl http://localhost:8000/api/posts/ \
  -H "Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"

Token 접두사가 핵심 — JWT의 Bearer와 다릅니다.

TokenAuthentication의 한계 #

  • 만료 없음 — 토큰이 영구. 폐기하려면 DB에서 삭제해야.
  • stateful — 검증할 때마다 DB 조회.
  • refresh 메커니즘 없음.

작은 내부 도구나 프로토타입에는 충분하지만, 일반 사용자 서비스에는 JWT가 더 어울립니다.

JWT — djangorestframework-simplejwt #

JWT는 stateless, 만료/refresh가 표준 — FastAPI #4 인증에서 다룬 그 JWT와 같은 개념입니다. DRF 진영의 표준 라이브러리는 simplejwt.

설치
uv add djangorestframework-simplejwt
settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

from datetime import timedelta

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
    "AUTH_HEADER_TYPES": ("Bearer",),
}

ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION 조합이 권장 — refresh 할 때마다 새 refresh 토큰을 주고, 옛것은 블랙리스트로. 탈취된 토큰의 수명을 짧게 유지합니다 (블랙리스트는 INSTALLED_APPSrest_framework_simplejwt.token_blacklist 추가).

mysite/urls.py
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)

urlpatterns = [
    ...,
    path("api/auth/token/", TokenObtainPairView.as_view()),
    path("api/auth/token/refresh/", TokenRefreshView.as_view()),
    path("api/auth/token/verify/", TokenVerifyView.as_view()),
]

발급 흐름 #

로그인 — access + refresh 한 번에
curl -X POST http://localhost:8000/api/auth/token/ \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret"}'
응답
{
  "access": "eyJhbGciOi...",
  "refresh": "eyJhbGciOi..."
}
API 호출 — Bearer 접두사
curl http://localhost:8000/api/posts/ \
  -H "Authorization: Bearer eyJhbGciOi..."

Refresh #

Access가 만료되면 (30분 후), refresh 토큰으로 새 access를 받습니다.

refresh
curl -X POST http://localhost:8000/api/auth/token/refresh/ \
  -H "Content-Type: application/json" \
  -d '{"refresh":"eyJhbGciOi..."}'
{"access": "eyJhbGciOi...새 토큰..."}

TokenAuthentication vs JWT #

TokenAuthJWT (simplejwt)
저장소DB클라이언트만
검증 비용DB 조회시그니처 검증 (메모리)
만료없음 (수동 삭제)자동 (exp)
Refresh없음있음
폐기토큰 삭제 → 즉시만료까지 또는 블랙리스트
헤더 prefixTokenBearer
어울리는 경우내부 도구, 단순일반 사용자 서비스

Permission 클래스 #

인증이 끝나면 (request.user가 채워진 다음), **그 사람이 이 동작을 해도 되는가?**를 검사하는 게 permission.

빌트인 permission 클래스 #

클래스의미
AllowAny누구나 (인증 안 해도 됨)
IsAuthenticated로그인된 사용자만
IsAdminUseris_staff=True
IsAuthenticatedOrReadOnly읽기는 누구나, 쓰기는 로그인 사용자만
DjangoModelPermissionsDjango의 add_, change_, delete_ 권한 (중급 #4)
DjangoObjectPermissions객체 단위 권한 (django-guardian같이)

View 별 오버라이드 #

blog/views.py
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .models import Post
from .serializers import PostSerializer


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

IsAuthenticatedOrReadOnly — 글 목록/상세는 누구나 보고, 생성/수정/삭제는 로그인 사용자만.

액션별 permission — get_permissions #

같은 ViewSet 안에서 action마다 다른 권한이 필요하면.

액션별 권한
from rest_framework.permissions import IsAuthenticated, AllowAny


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    def get_permissions(self):
        if self.action in ["list", "retrieve"]:
            return [AllowAny()]
        return [IsAuthenticated()]

get_permissions()인스턴스 리스트를 반환합니다 (클래스가 아니라). ()로 호출하는 점에 주의.

객체 단위 permission — IsOwner만들기 #

자기 글만 수정/삭제할 수 있어야 합니다. 객체 단위 검사는 has_object_permission 메소드로.

blog/permissions.py
from rest_framework import permissions


class IsOwnerOrReadOnly(permissions.BasePermission):
    """객체 소유자만 수정/삭제. 읽기는 누구나."""

    def has_permission(self, request, view):
        # 컬렉션 레벨 — 인증된 사용자만 쓰기
        if request.method in permissions.SAFE_METHODS:
            return True
        return request.user and request.user.is_authenticated

    def has_object_permission(self, request, view, obj):
        # 객체 레벨 — 읽기는 누구나
        if request.method in permissions.SAFE_METHODS:
            return True
        # 쓰기는 소유자만
        return obj.author == request.user
blog/views.py
from .permissions import IsOwnerOrReadOnly


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = [IsOwnerOrReadOnly]
    ...

has_permission vs has_object_permission #

DRF는 두 단계로 검사합니다.

  1. has_permission — view 진입 시점. 모든 요청에 대해.
  2. has_object_permissionget_object() 호출 후. retrieve/update/destroy같이 단일 객체를 다룰 때.

list 액션은 객체가 없으므로 has_object_permission이 호출되지 않습니다 — list 응답에서 자기 것만 거르려면 get_queryset()에서 처리해야 하며, 이 부분은 다음 글에서 짚겠습니다.

권한 조합 — &, |, ~ #

여러 permission을 논리 연산자로 조합할 수 있습니다.

조합
from rest_framework.permissions import IsAuthenticated, IsAdminUser


class PostViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAuthenticated & (IsOwnerOrReadOnly | IsAdminUser)]

“인증된 사용자 그리고 (소유자거나 admin)” — 자연스러운 표현.

자주 쓰는 패턴 — IsAdminOrReadOnly, role 기반 #

blog/permissions.py 확장
class IsAdminOrReadOnly(permissions.BasePermission):
    """admin만 쓰기. 누구나 읽기."""

    def has_permission(self, request, view):
        if request.method in permissions.SAFE_METHODS:
            return True
        return request.user and request.user.is_staff


class HasRole(permissions.BasePermission):
    """user.profile.role 기반."""

    required_role: str = ""

    def has_permission(self, request, view):
        return (
            request.user
            and request.user.is_authenticated
            and getattr(request.user.profile, "role", None) == self.required_role
        )


def has_role(role: str):
    """팩토리 — `HasRole` 인스턴스를 만드는 클래스 생성."""
    return type(f"HasRole_{role}", (HasRole,), {"required_role": role})


class PostViewSet(viewsets.ModelViewSet):
    permission_classes = [has_role("editor")]

FastAPI #4의 require_role 패턴과 닮은 구조 — 함수가 클래스를 반환합니다.

Throttling — Bonus, 인증과 같이 쓰는 기능 #

권한과 별개로, 요청 횟수 제한도 같은 지점에서 같이 잡는 게 일반적입니다.

settings.py
REST_FRAMEWORK = {
    ...,
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "100/hour",
        "user": "1000/hour",
    },
}

익명 사용자는 시간당 100 회, 인증된 사용자는 1000 회. 캐시 백엔드가 필요합니다 (CACHES 설정 — 고급 #4 캐싱 참고).

자주 만나는 함정 #

1) 인증 클래스 순서 #

🚫 Session이 먼저면 CSRF가 강제됨
"DEFAULT_AUTHENTICATION_CLASSES": [
    "rest_framework.authentication.SessionAuthentication",   # 먼저
    "rest_framework_simplejwt.authentication.JWTAuthentication",
],

JWT만 쓰는 모바일/SPA 클라이언트는 보통 CSRF 토큰을 안 보냅니다. SessionAuth가 먼저면 CSRF 검사에 걸립니다. JWT를 먼저 두거나, SessionAuth를 빼세요.

2) DEFAULT_PERMISSION_CLASSES가 비면 누구나 #

🚫 비어있으면 AllowAny와 같음
"DEFAULT_PERMISSION_CLASSES": [],

명시적으로 IsAuthenticated를 글로벌 기본으로 두고, 공개 엔드포인트는 view 별로 [AllowAny] 오버라이드 — deny by default가 안전합니다.

3) has_object_permission만으로는 부족 #

🚫 has_permission 누락
class IsOwner(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.author == request.user

list 액션에는 호출되지 않으므로, 글로벌 권한 검사를 같이 두지 않으면 비인증 사용자가 list를 볼 수 있습니다. **has_permission**으로 인증 여부를 먼저 검사하세요.

4) JWT secret = Django SECRET_KEY 그대로 #

simplejwt는 기본으로 SECRET_KEY를 사용합니다. 노출되면 토큰 위조가 가능. SECRET_KEY 자체를 환경 변수로 강하게 관리하거나, SIGNING_KEY를 별도로 설정하세요.

settings.py
SIMPLE_JWT["SIGNING_KEY"] = os.environ["JWT_SIGNING_KEY"]

정리 #

이번 글에서 잡은 것:

  • DRF 인증 vs Django 본체 인증 — 같은 User를 다른 트랜스포트로
  • 인증 클래스 — SessionAuth, TokenAuth, JWT (simplejwt), OAuth2
  • DEFAULT_AUTHENTICATION_CLASSES, DEFAULT_PERMISSION_CLASSES 글로벌 설정
  • TokenAuthenticationauthtoken 앱, signal로 자동 토큰 생성, Token <key> 헤더
  • JWT (simplejwt)TokenObtainPairView, TokenRefreshView, refresh rotation, Bearer 헤더
  • 권한 클래스 — AllowAny, IsAuthenticated, IsAdminUser, IsAuthenticatedOrReadOnly
  • permission_classes view 별 오버라이드, get_permissions() 액션별 분기
  • 객체 단위 권한has_permission + has_object_permission 두 단계
  • IsOwnerOrReadOnly 커스텀 패턴
  • 권한 조합 &, |, ~
  • Throttling으로 요청 횟수 제한
  • 함정 — 인증 순서, 비어있는 perm, list에 객체 권한 적용 안 됨, JWT secret

FastAPI #4Depends(get_current_user) 패턴이 DRF에서는 permission_classes + request.user로 풀립니다. 의존성 주입의 풍부함은 FastAPI가, 빌트인 권한 시스템의 깊이는 Django/DRF가 더 강합니다.

다음 글(#3 Filtering / Ordering / Pagination)에서는 큰 데이터를 다루는 표준 도구들 — 필터링, 정렬, 페이지네이션 — 을 다룹니다.

X