跳轉到

使用者權限限制

開始之前

任務目標

在這個章節中,我們會完成:

  • 了解 Django 的權限系統
  • 使用 @permission_required 限制特定權限
  • 在 Admin 中管理使用者權限
  • 在模板中根據權限顯示內容
  • 自訂 403 錯誤頁面

為什麼需要權限控制?

在前一個章節中,我們使用 @login_required 保護了文章的建立、編輯、刪除功能。但目前有個問題:

❌ 任何登入的使用者都可以建立文章
❌ 任何登入的使用者都可以編輯文章
❌ 任何登入的使用者都可以刪除文章

這在實際應用中可能不合理。我們可能需要:

✅ 只有特定權限的使用者才能建立文章
✅ 只有特定權限的使用者才能編輯文章
✅ 只有特定權限的使用者才能刪除文章
✅ 可以將使用者分組管理權限

這就是「權限控制」要解決的問題。

Django 的權限系統

在前一個章節中,我們簡單介紹了 Django 的權限系統。現在讓我們深入了解。

Permission(權限)

Django 會為每個 model 自動建立 4 個預設權限

權限代碼 說明 範例(Article model)
add 建立 blog.add_article
change 修改 blog.change_article
delete 刪除 blog.delete_article
view 檢視 blog.view_article

權限命名格式

權限的格式為:<app_label>.<action>_<model_name>

  • blog.add_article:在 blog app 中建立 Article
  • blog.change_article:在 blog app 中修改 Article
  • blog.delete_article:在 blog app 中刪除 Article
  • blog.view_article:在 blog app 中檢視 Article

Groups(群組)

群組可以將多個權限組合在一起,方便管理:

graph TD
    A[編輯群組] --> B[add_article]
    A --> C[change_article]
    A --> D[view_article]

    E[讀者群組] --> F[view_article]

    G[管理員群組] --> H[add_article]
    G --> I[change_article]
    G --> J[delete_article]
    G --> K[view_article]

User Permissions(使用者權限)

使用者可以透過以下方式獲得權限:

  1. 直接指派:直接給使用者特定權限
  2. 透過群組:將使用者加入群組,繼承群組的所有權限
  3. Superuser:超級使用者自動擁有所有權限

檢查權限

在 Python 中檢查使用者是否有特定權限:

Python 檢查權限
# 檢查單一權限
if user.has_perm('blog.add_article'):
    # 使用者有建立文章的權限
    pass

# 檢查多個權限
if user.has_perms(['blog.add_article', 'blog.change_article']):
    # 使用者同時有建立和修改文章的權限
    pass

在 Template 中檢查權限:

Template 檢查權限
{% if perms.blog.add_article %}
    <a href="{% url 'blog:article_create' %}">建立文章</a>
{% endif %}

{% if perms.blog.change_article %}
    <a href="{% url 'blog:article_edit' article.id %}">編輯文章</a>
{% endif %}

perms 變數

在 Django 模板中,perms 變數會自動提供,可以用來檢查當前使用者的權限。

使用方式:perms.<app_label>.<action>_<model_name>

使用 @permission_required 裝飾器

Django 提供了 @permission_required 裝飾器來限制特定權限才能訪問的 views。

基本用法

基本用法
from django.contrib.auth.decorators import permission_required

@permission_required('blog.add_article')  # (1)!
def article_create(request):
    # 只有擁有 blog.add_article 權限的使用者才能訪問
    ...

@permission_required(['blog.change_article', 'blog.delete_article'])  # (2)!
def article_edit(request, article_id):
    # 需要同時擁有兩個權限
    ...

@permission_required('blog.delete_article', raise_exception=True)  # (3)!
def article_delete(request, article_id):
    # 如果沒有權限,會顯示 403 錯誤頁面
    ...
  1. 需要單一權限
  2. 需要多個權限(使用 list)
  3. 使用 raise_exception=True 會在權限不足時顯示 403 錯誤,而不是重導向到登入頁面

@permission_required vs @login_required

  • @login_required:只檢查是否登入
  • @permission_required:檢查是否有特定權限(同時也會檢查是否登入)

@permission_required 包含了 @login_required 的功能,所以不需要同時使用兩者。

實作權限限制

現在讓我們在文章功能中加入權限限制:

編輯 blog/views.py

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


def article_list(request):
    filter_ = ArticleFilter(
        request.GET or None,
        queryset=Article.objects.select_related("author").prefetch_related("tags"),
    )
    return render(request, "blog/article_list.html", {"filter": filter_})


def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})


@permission_required("blog.add_article", raise_exception=True)  # (1)!
def article_create(request):
    form = ArticleForm(request.POST or None)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功建立。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_create.html", {"form": form})


@permission_required("blog.change_article", raise_exception=True)  # (2)!
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, instance=article)
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)  # (3)!
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 建立文章需要 blog.add_article 權限
  2. 編輯文章需要 blog.change_article 權限
  3. 刪除文章需要 blog.delete_article 權限

程式碼重點

  1. 匯入 permission_requiredfrom django.contrib.auth.decorators import permission_required
  2. 取代 @login_required:使用 @permission_required 取代原本的 @login_required
  3. raise_exception=True:當權限不足時顯示 403 錯誤頁面
  4. 權限格式app_label.action_modelname(如 blog.add_article

在 Admin 中管理權限

現在讓我們在 Admin 中為使用者設定權限。

步驟 1:建立測試使用者

使用 Django shell 建立一個測試使用者:

uv run manage.py shell

在 Python shell 中:

Python Shell
from django.contrib.auth.models import User

# 建立一般使用者(沒有任何權限)
user = User.objects.create_user(
    username="editor",
    email="editor@example.com",
    password="editor123"
)
print(f"使用者 {user.username} 建立成功!")

步驟 2:在 Admin 中設定權限

  1. 啟動開發伺服器:uv run manage.py runserver
  2. 訪問 Admin 後台:http://127.0.0.1:8000/admin/
  3. 使用 superuser 帳號登入
  4. 點擊「Users」
  5. 點擊剛才建立的「editor」使用者
  6. 在「User permissions」區域,找到以下權限並加入:
    • blog | article | Can add article
    • blog | article | Can change article
    • blog | article | Can view article
  7. 點擊「Save」儲存

快速搜尋權限

在「User permissions」的選擇框中,可以輸入關鍵字(如 article)來快速搜尋相關權限。

步驟 3:使用群組管理權限

如果有多個使用者需要相同的權限,使用群組會更方便。

  1. 在 Admin 後台點擊「Groups」
  2. 點擊「Add group」
  3. 輸入群組名稱:編輯者
  4. 在「Permissions」區域,加入以下權限:
    • blog | article | Can add article
    • blog | article | Can change article
    • blog | article | Can view article
  5. 點擊「Save」儲存
  6. 回到「Users」,編輯「editor」使用者
  7. 在「Groups」區域,將「編輯者」群組加入(可以移除之前直接指派的權限)
  8. 點擊「Save」儲存

群組的好處

使用群組管理權限的好處:

  1. 集中管理:修改群組權限會自動套用到所有成員
  2. 易於維護:不需要逐一修改每個使用者的權限
  3. 語意清晰:群組名稱可以清楚表達角色(如「編輯者」、「管理員」)

測試權限限制

現在讓我們測試權限是否正常運作:

  1. 登出 superuser 帳號
  2. 使用「editor」帳號登入(帳號:editor,密碼:editor123
  3. 嘗試建立文章 - ✅ 可以(有 add_article 權限)
  4. 嘗試編輯文章 - ✅ 可以(有 change_article 權限)
  5. 嘗試刪除文章 - ❌ 會看到 403 錯誤(沒有 delete_article 權限)

權限控制生效!

現在權限系統已經正常運作:

  • ✅ 沒有權限的使用者會看到 403 錯誤
  • ✅ 有權限的使用者可以正常操作
  • ✅ 可以透過 Admin 管理權限

在模板中根據權限顯示內容

為了提升使用者體驗,我們應該在模板中根據權限隱藏使用者無法使用的功能。

編輯 blog/templates/blog/article_list.html

blog/templates/blog/article_list.html
{% extends "blog/base.html" %}

{% load django_bootstrap5 %}

{% block title %}
  文章列表 - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="d-flex justify-content-between align-items-center mb-4">
    <h2>
      文章列表
    </h2>
    {% if perms.blog.add_article %}  <!-- (1)! -->
      <a href="{% url 'blog:article_create' %}" class="btn btn-primary">建立文章</a>
    {% endif %}
  </div>

  <div class="card mb-4">
    <div class="card-body">
      <h5 class="card-title">
        篩選條件
      </h5>
      <form method="get">
        {% bootstrap_form filter.form %}

        <div class="d-flex gap-2">
          {% bootstrap_button button_type="submit" content="搜尋" %}
          <a href="{% url 'blog:article_list' %}" class="btn btn-secondary">清除</a>
        </div>
      </form>
    </div>
  </div>

  <div class="row">
    {% for article in filter.qs %}
      {% include "blog/components/article_card.html" %}
    {% empty %}
      <div class="col-12">
        <div class="alert alert-info">
          沒有符合條件的文章
        </div>
      </div>
    {% endfor %}
  </div>
{% endblock blog_content %}
  1. 只有擁有 blog.add_article 權限的使用者才能看到「建立文章」按鈕

編輯 blog/templates/blog/article_detail.html

blog/templates/blog/article_detail.html
{% extends "blog/base.html" %}

{% block title %}
  {{ article.title }} - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <nav aria-label="breadcrumb" class="d-none d-md-block">
    <ol class="breadcrumb">
      <li class="breadcrumb-item">
        <a href="{% url 'blog:article_list' %}">文章列表</a>
      </li>
      <li class="breadcrumb-item active">
        {{ article.title }}
      </li>
    </ol>
  </nav>

  <article class="card">
    <div class="card-body">
      <h1 class="card-title fs-3 fs-md-2 fs-lg-1">
        {{ article.title }}
      </h1>

      <div class="d-flex flex-column flex-md-row align-items-md-center mb-3 text-muted">
        <span class="me-md-3 mb-2 mb-md-0">
          <i class="bi bi-person"></i>
          作者:{{ article.author.name }}
        </span>
        <span>
          <i class="bi bi-calendar"></i>
          發布時間:{{ article.created_at|date:"Y-m-d H:i" }}
        </span>
      </div>

      {% if article.tags.exists %}
        <div class="mb-3">
          {% for tag in article.tags.all %}
            <span class="badge bg-secondary me-1 mb-1">{{ tag.name }}</span>
          {% endfor %}
        </div>
      {% endif %}

      <hr />

      <div class="article-content fs-6 fs-md-5">
        {{ article.content|linebreaks }}
      </div>
    </div>

    <div class="card-footer bg-light">
      <div class="d-flex justify-content-between align-items-center">
        <div class="d-flex gap-2">
          <a href="{% url 'blog:article_list' %}"
             class="btn btn-outline-secondary btn-sm">
            <i class="bi bi-arrow-left"></i>
            <span class="d-none d-sm-inline">返回列表</span>
          </a>
          {% if perms.blog.change_article %}  <!-- (1)! -->
            <a href="{% url 'blog:article_edit' article_id=article.id %}"
               class="btn btn-outline-primary btn-sm">
              <i class="bi bi-pencil"></i>
              <span class="d-none d-sm-inline">編輯</span>
            </a>
          {% endif %}
          {% if perms.blog.delete_article %}  <!-- (2)! -->
            <a href="{% url 'blog:article_delete' article_id=article.id %}"
               class="btn btn-outline-danger btn-sm">
              <i class="bi bi-trash"></i>
              <span class="d-none d-sm-inline">刪除</span>
            </a>
          {% endif %}
        </div>
        <small class="text-muted d-none d-md-inline">
          最後更新:{{ article.updated_at|date:"Y-m-d" }}
        </small>
      </div>
    </div>
  </article>
{% endblock blog_content %}
  1. 只有擁有 blog.change_article 權限的使用者才能看到「編輯」按鈕
  2. 只有擁有 blog.delete_article 權限的使用者才能看到「刪除」按鈕

前端隱藏 vs 後端驗證

雖然我們在前端隱藏了按鈕,但後端驗證仍然是必要的,因為:

  1. 使用者可以直接訪問 URL
  2. 使用者可以使用瀏覽器開發工具修改前端
  3. 安全性永遠不能只依賴前端

前端隱藏只是為了提升使用者體驗,真正的安全保護在後端的 @permission_required 裝飾器。

自訂 403 錯誤頁面

當使用者權限不足時,Django 預設會顯示簡陋的 403 錯誤頁面。我們可以自訂這個頁面。

建立 templates/403.html

templates/403.html
{% extends "base.html" %}

{% block title %}
  權限不足 - Django 大冒險
{% endblock title %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-md-6">
      <div class="alert alert-danger">
        <h1 class="display-4">403</h1>
        <h4 class="alert-heading">權限不足</h4>
        <p>你沒有權限訪問這個頁面。</p>
        <hr />
        <p class="mb-0">
          <a href="{% url 'blog:article_list' %}" class="btn btn-primary">回到首頁</a>
        </p>
      </div>
    </div>
  </div>
{% endblock content %}

任務結束

完成!

恭喜你完成了這個章節!現在你已經:

  • 了解 Django 的權限系統
  • 使用 @permission_required 限制特定權限
  • 在 Admin 中管理使用者權限
  • 在模板中根據權限顯示內容
  • 自訂 403 錯誤頁面