장고 중급 #4 사용자/권한 — 커스텀 user model, permission, group

7 분 소요

기초 #7에서 장고의 빌트인 인증을 다졌습니다 — 로그인/로그아웃, @login_required, User 모델. 실무에 들어가면 곧 두 가지 질문을 만납니다.

  1. “username 대신 이메일로 로그인하고 싶은데?”
  2. “사용자에게 세밀한 권한을 주려면?”

이 글이 그 답입니다. 한 가지 무거운 경고로 시작합니다 — AUTH_USER_MODEL은 프로젝트 시작 시점에 정해야 합니다.

무거운 경고 — 시작 시점에 정해야 할 결정 #

장고 공식 문서가 강조하는 한 줄: “Even if you’re happy with the default User model, you’ll likely want to change it later. So if you’re starting a new project, definitely set up a custom user model, even if it looks just like Django’s default user model.”

(번역: 기본 User 모델로 만족스럽더라도 나중에 바꾸고 싶을 가능성이 높습니다. 새 프로젝트라면 기본 User와 똑같이 생긴 것이라도 반드시 커스텀 user model로 시작하세요.)

이유는 마이그레이션 후에 바꾸기가 매우 어렵기 때문입니다. 모든 외래키, 모든 마이그레이션 히스토리, 모든 데이터를 옮겨야 합니다. 새 프로젝트에선 다음 두 줄을 첫 마이그레이션 전에 잡으세요.

settings.py
AUTH_USER_MODEL = "accounts.User"
accounts/models.py — 최소
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    pass

지금 당장 추가 필드가 없어도 자기 User 클래스를 가지고 시작합니다. 나중에 필드를 추가하기는 쉽기 때문입니다.

AbstractUser vs AbstractBaseUser #

커스터마이즈 깊이에 따라 두 갈래가 있습니다.

AbstractUserAbstractBaseUser + PermissionsMixin
기본 필드username, email, first_name, last_name, …password, last_login만
권한 시스템자동 포함PermissionsMixin으로 추가
추천 용도대부분 (90%) — 약간만 추가완전 다른 사용자 모델 (이메일만 등)
작업량매우 적음많음 (UserManager 직접 작성)

거의 모든 경우에 AbstractUser가 답. 정말 username 자체를 없애고 이메일만 쓰고 싶을 때만 AbstractBaseUser.

AbstractUser — 필드 추가만 #

accounts/models.py — 필드 추가
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    bio = models.TextField(blank=True)
    avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
    birth_date = models.DateField(null=True, blank=True)

    def __str__(self):
        return self.username

이게 끝입니다. username, email, first_name, last_name, is_staff, is_active 등은 그대로 살아 있습니다.

이메일 로그인 패턴 — AbstractBaseUser #

실무에서 자주 만나는 요구 — 이메일을 username으로 쓰기. 이때는 AbstractBaseUser + 커스텀 매니저 조합이 표준입니다.

accounts/models.py — 이메일 로그인
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone


class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, password, **extra):
        if not email:
            raise ValueError("이메일은 필수입니다.")
        email = self.normalize_email(email)
        user = self.model(email=email, **extra)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, password=None, **extra):
        extra.setdefault("is_staff", False)
        extra.setdefault("is_superuser", False)
        return self._create_user(email, password, **extra)

    def create_superuser(self, email, password=None, **extra):
        extra.setdefault("is_staff", True)
        extra.setdefault("is_superuser", True)
        if extra.get("is_staff") is not True:
            raise ValueError("superuser는 is_staff=True 여야 합니다.")
        if extra.get("is_superuser") is not True:
            raise ValueError("superuser는 is_superuser=True 여야 합니다.")
        return self._create_user(email, password, **extra)


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    name = models.CharField(max_length=100, blank=True)

    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    date_joined = models.DateTimeField(default=timezone.now)

    objects = UserManager()

    USERNAME_FIELD = "email"        # 로그인에 쓸 필드
    REQUIRED_FIELDS = []            # createsuperuser가 묻는 추가 필드 (email/password 외)

    def __str__(self):
        return self.email

체크포인트:

  • USERNAME_FIELD = "email" — 로그인 식별자. unique=True 필수
  • REQUIRED_FIELDScreatesuperuser 명령이 추가로 물어볼 필드. USERNAME_FIELDpassword는 자동 포함이라 여기 적지 않음
  • **UserManager**의 create_user / create_superusermanage.py createsuperuser 등에서 호출됨
  • **PermissionsMixin**을 같이 상속해야 그룹/퍼미션 시스템이 동작

admin 등록 #

createsuperuser가 동작해도 admin의 사용자 변경 폼은 기본 User 기준이라 깨집니다. UserAdmin을 직접 등록해야 합니다.

accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import User


class UserAdmin(BaseUserAdmin):
    ordering = ("email",)
    list_display = ("email", "name", "is_staff", "is_active")
    search_fields = ("email", "name")

    fieldsets = (
        (None, {"fields": ("email", "password")}),
        ("개인 정보", {"fields": ("name",)}),
        ("권한", {"fields": ("is_active", "is_staff", "is_superuser",
                          "groups", "user_permissions")}),
        ("기록", {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (None, {"classes": ("wide",),
                "fields": ("email", "password1", "password2")}),
    )

admin.site.register(User, UserAdmin)

회원가입/로그인 폼도 username 기반의 빌트인 폼을 그대로 못 쓰니, UserCreationForm / AuthenticationForm을 상속해 이메일 기반으로 다시 만듭니다.

Permission 시스템 #

장고는 모델당 자동으로 4 개의 퍼미션을 만듭니다.

퍼미션 코드의미
add_<model>추가
change_<model>수정
delete_<model>삭제
view_<model>조회

blog 앱의 Post 모델이라면 blog.add_post, blog.change_post, blog.delete_post, blog.view_post가 자동 생성됩니다.

코드에서 검사 #

has_perm 사용
if request.user.has_perm("blog.change_post"):
    # 수정 권한 있음
    ...

if request.user.has_perms(["blog.change_post", "blog.delete_post"]):
    # 둘 다 있음
    ...

데코레이터 / Mixin #

FBV — @permission_required
from django.contrib.auth.decorators import permission_required

@permission_required("blog.change_post", raise_exception=True)
def post_edit(request, pk):
    ...
CBV — PermissionRequiredMixin
from django.contrib.auth.mixins import PermissionRequiredMixin

class PostUpdateView(PermissionRequiredMixin, UpdateView):
    permission_required = "blog.change_post"
    raise_exception = True

#1 CBV에서 본 그 Mixin입니다.

커스텀 퍼미션 #

모델 Meta에 정의해서 자동 생성에 추가할 수 있습니다.

커스텀 퍼미션
class Post(models.Model):
    ...
    class Meta:
        permissions = [
            ("publish_post", "Can publish post"),
            ("feature_post", "Can feature post on homepage"),
        ]

makemigrationsblog.publish_post로 사용합니다. admin의 사용자/그룹 편집에서 부여할 수 있습니다.

Group — 퍼미션 묶음 #

권한을 사용자에게 직접 주면 관리가 어렵습니다. Group으로 묶어서 사용자에게 부여하는 게 표준입니다.

그룹 만들기 (셸/마이그레이션)
from django.contrib.auth.models import Group, Permission

editors, _ = Group.objects.get_or_create(name="에디터")
editors.permissions.add(
    Permission.objects.get(codename="add_post"),
    Permission.objects.get(codename="change_post"),
    Permission.objects.get(codename="publish_post"),
)

# 사용자에게 그룹 부여
user.groups.add(editors)

admin 페이지의 그룹 메뉴에서도 같은 일을 할 수 있습니다. 보통 마이그레이션이나 시드 명령으로 그룹/퍼미션 초기값을 코드에 적어두는 게 재현성 측면에서 좋습니다.

data migration으로 그룹 만들기
from django.db import migrations

def create_default_groups(apps, schema_editor):
    Group = apps.get_model("auth", "Group")
    Permission = apps.get_model("auth", "Permission")
    editors, _ = Group.objects.get_or_create(name="에디터")
    perms = Permission.objects.filter(
        codename__in=["add_post", "change_post", "publish_post"]
    )
    editors.permissions.set(perms)

class Migration(migrations.Migration):
    dependencies = [("blog", "0001_initial")]
    operations = [migrations.RunPython(create_default_groups, migrations.RunPython.noop)]

템플릿에서 그룹/퍼미션 검사 #

템플릿
{% if user.is_authenticated %}
  {% if perms.blog.publish_post %}
    <a href="{% url 'post_publish' post.pk %}">발행</a>
  {% endif %}
{% endif %}

permsdjango.contrib.auth.context_processors.auth 컨텍스트 프로세서가 넘기는 헬퍼입니다. settings.pyTEMPLATES.OPTIONS.context_processors에 들어 있으면 자동.

Object-level permission — 한 줄 안내 #

장고 빌트인 퍼미션은 모델 단위 입니다 — “이 사용자가 Post를 수정할 수 있는가” 까지만. “이 사용자가 이 특정 Post를 수정할 수 있는가” (객체 단위)는 없습니다.

객체 단위 권한이 필요하면:

  • UserPassesTestMixin (#1)로 직접 검사 — 작은 경우엔 충분
  • django-guardian 외부 라이브러리 — 본격 객체 권한
  • DRF의 permission_classes — REST API 라면 (실전 #2)

작은 경우엔 직접 검사가 깔끔합니다.

작성자만 수정
class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Post
    form_class = PostForm

    def test_func(self):
        return self.get_object().author == self.request.user

비밀번호 — 해시 / 변경 / 재설정 #

장고는 비밀번호를 PBKDF2 (기본) 등 안전한 해시로 자동 저장합니다. 직접 만질 일은 거의 없습니다.

비밀번호 다루기
user.set_password("새비밀번호")        # 해시해서 저장
user.save()

user.check_password("입력값")          # 검증

평문 비밀번호를 저장하지 마세요. user.password = "..."로 직접 대입하면 평문이 들어가니 반드시 set_password를 사용해야 합니다.

빌트인 패스워드 변경/재설정 뷰는 django.contrib.auth.urls에 들어있습니다.

urls.py
from django.urls import path, include

urlpatterns = [
    path("accounts/", include("django.contrib.auth.urls")),
    # /accounts/login/, /accounts/logout/, /accounts/password_change/,
    # /accounts/password_reset/ 등
]

DRF의 인증과의 차이 — 짧게 #

이번 글의 인증은 세션 + 쿠키 (브라우저 폼 로그인) 모델입니다. REST API는 보통 토큰 / JWT / OAuth2 등 stateless 인증을 씁니다.

장고 빌트인 (이번 글)DRF
인증 매체세션 쿠키Token / JWT / OAuth2
권한 검사has_perm / Mixin / 데코레이터permission_classes
사용자 모델동일 (AUTH_USER_MODEL)동일
로그인 폼빌트인 HTML별도 엔드포인트

같은 User 모델을 쓰지만 인증 매체와 권한 검사 성격이 다른 구조라고 보면 됩니다. DRF의 인증/권한 스택은 실전 #2에서 자세히 다루겠습니다.

request.user의 정체 #

AuthenticationMiddleware (#3에서 본 그 미들웨어)가 모든 요청에 request.user를 채워 넣습니다.

  • 로그인 됐으면User 인스턴스
  • 안 됐으면AnonymousUser (가짜 객체. is_authenticated == False)
자주 쓰는 검사
if request.user.is_authenticated:
    ...

if request.user.is_staff:
    ...

if request.user.is_superuser:
    ...

is_authenticated속성 입니다 (메소드 아님). request.user.is_authenticated()로 호출하면 안 됩니다 — 옛 장고 1.x 시절엔 메소드였지만 지금은 속성.

정리 #

이번 글에서 잡은 것:

  • **AUTH_USER_MODEL**은 프로젝트 시작 시점에 잡기 — 나중 변경은 매우 비쌈
  • 거의 모든 경우에 **AbstractUser**가 답 (필드만 추가)
  • 이메일 로그인 — AbstractBaseUser + PermissionsMixin + 커스텀 UserManager
  • USERNAME_FIELD, REQUIRED_FIELDS의 의미
  • 모델당 자동 4개 퍼미션 (add/change/delete/view_<model>)
  • has_perm, @permission_required, PermissionRequiredMixin
  • 모델 Meta.permissions로 커스텀 퍼미션
  • Group으로 권한 묶기 — 사용자에게는 그룹을 부여
  • 객체 단위 권한은 UserPassesTestMixin 또는 django-guardian / DRF
  • 비밀번호는 set_password / check_password, 빌트인 변경/재설정 뷰
  • request.user.is_authenticated는 속성

다음 글(#5 메시지/세션/쿠키)에서는 인증된 사용자에게 보내는 flash 메시지, 요청 사이의 상태 저장을 위한 세션, 그리고 그 밑단의 쿠키 까지를 한 글에 풉니다.

X