使用者權限限制¶
開始之前¶
任務目標
在這個章節中,我們會完成:
- 了解 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 中建立 Articleblog.change_article:在 blog app 中修改 Articleblog.delete_article:在 blog app 中刪除 Articleblog.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(使用者權限)¶
使用者可以透過以下方式獲得權限:
- 直接指派:直接給使用者特定權限
- 透過群組:將使用者加入群組,繼承群組的所有權限
- Superuser:超級使用者自動擁有所有權限
檢查權限¶
在 Python 中檢查使用者是否有特定權限:
# 檢查單一權限
if user.has_perm('blog.add_article'):
# 使用者有建立文章的權限
pass
# 檢查多個權限
if user.has_perms(['blog.add_article', 'blog.change_article']):
# 使用者同時有建立和修改文章的權限
pass
在 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 錯誤頁面
...
- 需要單一權限
- 需要多個權限(使用 list)
- 使用
raise_exception=True會在權限不足時顯示 403 錯誤,而不是重導向到登入頁面
@permission_required vs @login_required
@login_required:只檢查是否登入@permission_required:檢查是否有特定權限(同時也會檢查是否登入)
@permission_required 包含了 @login_required 的功能,所以不需要同時使用兩者。
實作權限限制¶
現在讓我們在文章功能中加入權限限制:
編輯 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})
- 建立文章需要
blog.add_article權限 - 編輯文章需要
blog.change_article權限 - 刪除文章需要
blog.delete_article權限
程式碼重點
- 匯入 permission_required:
from django.contrib.auth.decorators import permission_required - 取代 @login_required:使用
@permission_required取代原本的@login_required - raise_exception=True:當權限不足時顯示 403 錯誤頁面
- 權限格式:
app_label.action_modelname(如blog.add_article)
在 Admin 中管理權限¶
現在讓我們在 Admin 中為使用者設定權限。
步驟 1:建立測試使用者¶
使用 Django 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 中設定權限¶
- 啟動開發伺服器:
uv run manage.py runserver - 訪問 Admin 後台:http://127.0.0.1:8000/admin/
- 使用 superuser 帳號登入
- 點擊「Users」
- 點擊剛才建立的「editor」使用者
- 在「User permissions」區域,找到以下權限並加入:
blog | article | Can add articleblog | article | Can change articleblog | article | Can view article
- 點擊「Save」儲存
快速搜尋權限
在「User permissions」的選擇框中,可以輸入關鍵字(如 article)來快速搜尋相關權限。
步驟 3:使用群組管理權限¶
如果有多個使用者需要相同的權限,使用群組會更方便。
- 在 Admin 後台點擊「Groups」
- 點擊「Add group」
- 輸入群組名稱:
編輯者 - 在「Permissions」區域,加入以下權限:
blog | article | Can add articleblog | article | Can change articleblog | article | Can view article
- 點擊「Save」儲存
- 回到「Users」,編輯「editor」使用者
- 在「Groups」區域,將「編輯者」群組加入(可以移除之前直接指派的權限)
- 點擊「Save」儲存
群組的好處
使用群組管理權限的好處:
- 集中管理:修改群組權限會自動套用到所有成員
- 易於維護:不需要逐一修改每個使用者的權限
- 語意清晰:群組名稱可以清楚表達角色(如「編輯者」、「管理員」)
測試權限限制¶
現在讓我們測試權限是否正常運作:
- 登出 superuser 帳號
- 使用「editor」帳號登入(帳號:
editor,密碼:editor123) - 嘗試建立文章 - ✅ 可以(有
add_article權限) - 嘗試編輯文章 - ✅ 可以(有
change_article權限) - 嘗試刪除文章 - ❌ 會看到 403 錯誤(沒有
delete_article權限)
權限控制生效!
現在權限系統已經正常運作:
- ✅ 沒有權限的使用者會看到 403 錯誤
- ✅ 有權限的使用者可以正常操作
- ✅ 可以透過 Admin 管理權限
在模板中根據權限顯示內容¶
為了提升使用者體驗,我們應該在模板中根據權限隱藏使用者無法使用的功能。
編輯 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 %}
- 只有擁有
blog.add_article權限的使用者才能看到「建立文章」按鈕
編輯 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 %}
- 只有擁有
blog.change_article權限的使用者才能看到「編輯」按鈕 - 只有擁有
blog.delete_article權限的使用者才能看到「刪除」按鈕
前端隱藏 vs 後端驗證
雖然我們在前端隱藏了按鈕,但後端驗證仍然是必要的,因為:
- 使用者可以直接訪問 URL
- 使用者可以使用瀏覽器開發工具修改前端
- 安全性永遠不能只依賴前端
前端隱藏只是為了提升使用者體驗,真正的安全保護在後端的 @permission_required 裝飾器。
自訂 403 錯誤頁面¶
當使用者權限不足時,Django 預設會顯示簡陋的 403 錯誤頁面。我們可以自訂這個頁面。
建立 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 錯誤頁面