跳轉到

使用者驗證

開始之前

任務目標

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

  • 了解 Django 內建的使用者驗證系統
  • 實作登入功能
  • 實作登出功能
  • 實作註冊功能
  • 實作密碼變更功能
  • 實作密碼重設功能
  • 使用 @login_required 保護 views

為什麼需要使用者驗證?

在前面的章節中,我們建立了文章的建立、編輯、刪除功能。但目前有個問題:

❌ 任何人都可以建立文章
❌ 任何人都可以編輯文章
❌ 任何人都可以刪除文章

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

✅ 只有登入的使用者才能建立文章
✅ 只有登入的使用者才能編輯文章
✅ 只有登入的使用者才能刪除文章

這就是「使用者驗證」要解決的問題。

Django 內建的使用者驗證系統

Django 提供了完整的使用者驗證系統,包含:

User Model

Django 內建的 User 模型包含以下欄位:

欄位 說明
username 使用者名稱(唯一)
password 密碼(加密儲存)
email 電子郵件
first_name 名字
last_name 姓氏
is_active 是否啟用
is_staff 是否為管理員
is_superuser 是否為超級使用者
date_joined 註冊日期
last_login 最後登入時間

User Model 的位置

from django.contrib.auth.models import User

Django 已經幫我們建立好了,可以直接使用!

權限系統

Django 的權限系統包含:

  1. Permissions(權限):細粒度的權限控制

    • 預設每個 model 有 4 個權限:addchangedeleteview
    • 可以自訂額外的權限
  2. Groups(群組):將使用者分組管理

    • 一個群組可以包含多個權限
    • 一個使用者可以屬於多個群組
  3. User Permissions(使用者權限)

    • 可以直接給使用者特定權限
    • 也可以透過群組繼承權限

在本章節中

我們會專注在「使用者驗證」(Authentication),先讓使用者可以登入、登出、註冊等。

「授權」(Authorization,權限控制)會在後續章節詳細說明。

實作登入功能

Django 提供了內建的 LoginView,我們只需要設定 URL 和模板即可。

步驟 1:設定 URL

驗證功能是全專案共用的,所以我們將它設定在專案的主要 URL 設定中。

編輯 core/urls.py

core/urls.py
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

auth_urlpatterns = [  # (1)!
    path(
        "login/",  # (2)!
        auth_views.LoginView.as_view(template_name="registration/login.html"),  # (3)!
        name="login",
    ),
]

urlpatterns = [
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),  # (4)!
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [*urlpatterns, *debug_toolbar_urls()]
  1. 獨立宣告驗證相關的 URL patterns
  2. URL 路徑為 login/(相對於 auth/ 前綴)
  3. 使用 Django 內建的 LoginView,模板放在 registration 資料夾(Django 慣例)
  4. 透過 include 引入,加上 /auth 前綴,namespace 為 auth

程式碼重點

  1. 匯入 auth viewsfrom django.contrib.auth import views as auth_views
  2. 獨立宣告auth_urlpatterns = [...] 讓程式碼更清晰
  3. 使用 includepath("auth/", include((auth_urlpatterns, "auth")))
  4. URL 前綴:所有驗證 URL 都會有 /auth/ 前綴(如 /auth/login/
  5. namespace:使用 auth namespace,引用時用 auth:login
  6. 模板路徑:使用 registration/ 資料夾,這是 Django 的慣例
  7. 全專案共用:所有 app 都可以使用這些驗證功能

步驟 2:建立登入模板

建立 templates/registration/login.html

templates/registration/login.html
{% extends "base.html" %}
{% load django_bootstrap5 %}

{% block title %}
  登入 - Django 大冒險
{% endblock title %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-md-6">
      <div class="card">
        <div class="card-header">
          <h2 class="mb-0">登入</h2>
        </div>
        <div class="card-body">
          <form method="post">
            {% csrf_token %}
            {% bootstrap_form form %}

            <div class="d-grid gap-2">
              <button type="submit" class="btn btn-primary">登入</button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
{% endblock content %}

步驟 3:設定登入後的重導向

編輯 core/settings.py,在檔案最後加入:

core/settings.py
LOGIN_REDIRECT_URL = "blog:article_list"  # (1)!

LOGIN_URL = "auth:login"  # (2)!
  1. 登入成功後重導向到文章列表頁
  2. 設定登入頁面的 URL name(使用 auth namespace)

步驟 4:建立測試帳號

使用 Django shell 建立測試帳號:

uv run manage.py shell

在 Python shell 中:

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

# 建立使用者
user = User.objects.create_user(
    username="test-user",
    email="test@example.com",
    password="test-pass123"
)
print(f"使用者 {user.username} 建立成功!")

不要使用 create()

使用 create_user() 而不是 create(),因為:

  • create_user() 會自動加密密碼
  • create() 會直接儲存明文密碼(不安全)

測試登入功能

啟動開發伺服器:

uv run manage.py runserver

訪問 http://127.0.0.1:8000/auth/login/

  1. 輸入帳號:test-user
  2. 輸入密碼:test-pass123
  3. 點擊「登入」
  4. 成功登入後會重導向到文章列表頁

登入成功!

現在你已經可以使用 Django 內建的登入功能了!

實作登出功能

Django 提供了內建的 LogoutView,讓我們加入登出功能。

使用 POST 方法登出

從 Django 3.0 開始,LogoutView 預設只接受 POST 請求,以防止 CSRF 攻擊。這意味著我們需要使用表單來提交登出請求,而不是簡單的連結。

步驟 1:設定 URL

編輯 core/urls.py,在 auth_urlpatterns 中加入登出 URL:

core/urls.py
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",  # (1)!
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
]

urlpatterns = [
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [*urlpatterns, *debug_toolbar_urls()]
  1. 加入登出 URL,使用 Django 內建的 LogoutView

步驟 2:設定登出後的重導向

編輯 core/settings.py,加入登出重導向設定:

core/settings.py
LOGOUT_REDIRECT_URL = "auth:login"  # (1)!
  1. 登出後重導向到登入頁面

步驟 3:在導覽列加入登入/登出連結

編輯 templates/base.html

templates/base.html
{% load django_bootstrap5 %}

<!DOCTYPE html>
<html lang="zh-Hant">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Django Playground" />
    <meta name="keywords" content="Django, Playground" />

    <title>
      {% block title %}
        Django 大冒險
      {% endblock title %}
    </title>

    {% bootstrap_css %}

    <link rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" />

    {% block extra_head %}
    {% endblock extra_head %}
  </head>
  <body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
      <div class="container">
        <span class="navbar-brand">Django 大冒險</span>
        <button class="navbar-toggler"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#navbarNav">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav ms-auto">
            <li class="nav-item">
              <a class="nav-link" href="{% url 'blog:article_list' %}">文章列表</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="{% url 'admin:index' %}">管理後台</a>
            </li>
            {% if user.is_authenticated %}  <!-- (1)! -->
              <li class="nav-item">
                <form method="post" action="{% url 'auth:logout' %}" class="d-inline">  <!-- (2)! -->
                  {% csrf_token %}
                  <button type="submit" class="nav-link btn btn-link">登出 ({{ user.username }})</button>  <!-- (3)! -->
                </form>
              </li>
            {% else %}  <!-- (4)! -->
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:login' %}">登入</a>
              </li>
            {% endif %}
          </ul>
        </div>
      </div>
    </nav>

    <main class="container my-4">
      {% bootstrap_messages %}

      {% block content %}
      {% endblock content %}
    </main>

    <footer class="bg-light py-4 mt-5">
      <div class="container text-center">
        <p class="text-muted mb-0">Django Playground</p>
      </div>
    </footer>

    {% bootstrap_javascript %}

    {% block extra_scripts %}
    {% endblock extra_scripts %}
  </body>
</html>
  1. 檢查使用者是否已登入
  2. 使用 POST 表單提交登出請求(Django 3.0+ 要求使用 POST 以防止 CSRF 攻擊)
  3. 在登出按鈕上顯示當前登入使用者名稱
  4. 未登入時顯示登入連結

登出按鈕樣式

我們使用 btn btn-link class 讓 <button> 看起來像連結,同時使用 nav-link class 保持導覽列的樣式一致。d-inline class 讓表單以 inline 方式顯示。

user 變數

在 Django 模板中,user 變數會自動提供:

  • user.is_authenticated:檢查是否已登入
  • user.username:使用者名稱
  • user.email:電子郵件
  • 其他 User model 的欄位

測試登出功能

  1. 確保你已登入
  2. 點擊導覽列的「登出 (username)」按鈕
  3. 成功登出後會重導向到登入頁面
  4. 登入頁面會顯示登入表單

登出功能完成!

現在你已經可以使用登入和登出功能了!

實作註冊功能

步驟 1:使用內建註冊表單

UserCreationForm 是 Django 內建的表單,包含:

  • username:使用者名稱
  • password1:密碼
  • password2:確認密碼

它會自動:

  • 檢查兩次密碼是否一致
  • 驗證密碼強度
  • 加密密碼

步驟 2:建立註冊 View

建立 core/views.py

core/views.py
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import redirect, render


def register(request):  # (1)!
    form = UserCreationForm(request.POST or None)  # (2)!
    if form.is_valid():
        user = form.save()  # (3)!
        login(request, user)  # (4)!
        messages.success(request, f"歡迎加入, {user.username}!")
        return redirect("blog:article_list")

    return render(request, "registration/register.html", {"form": form})
  1. 建立註冊 view
  2. 使用我們內建的 UserCreationForm
  3. 儲存新使用者
  4. 自動登入新使用者

程式碼重點

  1. 匯入 login 函式from django.contrib.auth import login - login() 用於手動登入使用者
  2. 註冊後自動登入login(request, user) - 讓新註冊的使用者自動登入
  3. 重導向到首頁return redirect("blog:article_list")

這邊為了方便,我們將註冊放在 core/ 中,但在大型專案中可以建立獨立的 app (userauthentication) 並放到裡面。

步驟 3:設定 URL

編輯 core/urls.py,在 auth_urlpatterns 中加入註冊 URL:

core/urls.py
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

from core import views  # (1)!

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
    path("register/", views.register, name="register"),  # (2)!
]

urlpatterns = [
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [*urlpatterns, *debug_toolbar_urls()]
  1. 匯入 core.views 以使用自訂的 register view
  2. 加入註冊 URL

步驟 4:建立註冊模板

建立 templates/registration/register.html

templates/registration/register.html
{% extends "base.html" %}
{% load django_bootstrap5 %}

{% block title %}
  註冊 - Django 大冒險
{% endblock title %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-md-6">
      <div class="card">
        <div class="card-header">
          <h2 class="mb-0">註冊</h2>
        </div>
        <div class="card-body">
          <form method="post">
            {% csrf_token %}
            {% bootstrap_form form %}

            <div class="d-grid gap-2">
              <button type="submit" class="btn btn-primary">註冊</button>
            </div>
          </form>

          <hr />

          <p class="text-center mb-0">
            已經有帳號?<a href="{% url 'auth:login' %}">立即登入</a>
          </p>
        </div>
      </div>
    </div>
  </div>
{% endblock content %}

步驟 5:更新登入模板

回到 templates/registration/login.html,加入註冊連結:

templates/registration/login.html
{% extends "base.html" %}
{% load django_bootstrap5 %}

{% block title %}
  登入 - Django 大冒險
{% endblock title %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-md-6">
      <div class="card">
        <div class="card-header">
          <h2 class="mb-0">登入</h2>
        </div>
        <div class="card-body">
          <form method="post">
            {% csrf_token %}
            {% bootstrap_form form %}

            <div class="d-grid gap-2">
              <button type="submit" class="btn btn-primary">登入</button>
            </div>
          </form>

          <hr />

          <p class="text-center mb-0">
            還沒有帳號?<a href="{% url 'auth:register' %}">立即註冊</a>  <!-- (1)! -->
          </p>
        </div>
      </div>
    </div>
  </div>
{% endblock content %}
  1. 加入註冊連結,方便使用者快速切換到註冊頁面

步驟 6:更新導覽列

回到 templates/base.html,在未登入時加入註冊連結:

templates/base.html
{% load django_bootstrap5 %}

<!DOCTYPE html>
<html lang="zh-Hant">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Django Playground" />
    <meta name="keywords" content="Django, Playground" />

    <title>
      {% block title %}
        Django 大冒險
      {% endblock title %}
    </title>

    {% bootstrap_css %}

    <link rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" />

    {% block extra_head %}
    {% endblock extra_head %}
  </head>
  <body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
      <div class="container">
        <span class="navbar-brand">Django 大冒險</span>
        <button class="navbar-toggler"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#navbarNav">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav ms-auto">
            <li class="nav-item">
              <a class="nav-link" href="{% url 'blog:article_list' %}">文章列表</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="{% url 'admin:index' %}">管理後台</a>
            </li>
            {% if user.is_authenticated %}
              <li class="nav-item">
                <form method="post" action="{% url 'auth:logout' %}" class="d-inline">
                  {% csrf_token %}
                  <button type="submit" class="nav-link btn btn-link">登出 ({{ user.username }})</button>
                </form>
              </li>
            {% else %}
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:login' %}">登入</a>
              </li>
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:register' %}">註冊</a>  <!-- (1)! -->
              </li>
            {% endif %}
          </ul>
        </div>
      </div>
    </nav>

    <main class="container my-4">
      {% bootstrap_messages %}

      {% block content %}
      {% endblock content %}
    </main>

    <footer class="bg-light py-4 mt-5">
      <div class="container text-center">
        <p class="text-muted mb-0">Django Playground</p>
      </div>
    </footer>

    {% bootstrap_javascript %}

    {% block extra_scripts %}
    {% endblock extra_scripts %}
  </body>
</html>
  1. 未登入時顯示註冊連結

測試註冊功能

  1. 先登出(如果已登入)
  2. 點擊導覽列的「註冊」連結
  3. 填寫註冊表單:
    • 使用者名稱:user001
  4. 點擊「註冊」
  5. 註冊成功後會自動登入並重導向到文章列表頁
  6. 導覽列的登出按鈕會顯示「登出 (user001)」

註冊成功!

現在使用者可以自行註冊帳號了!

實作密碼變更功能

Django 提供了內建的密碼變更 views:PasswordChangeViewPasswordChangeDoneView

步驟 1:設定 URL

編輯 core/urls.py,在 auth_urlpatterns 中加入密碼變更 URL:

core/urls.py
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path, reverse_lazy

from core import views

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
    path("register/", views.register, name="register"),
    path(
        "password-change/",  # (1)!
        auth_views.PasswordChangeView.as_view(
            template_name="registration/password_change.html",
            success_url=reverse_lazy("auth:password_change_done"),
        ),
        name="password_change",
    ),
    path(
        "password-change/done/",  # (2)!
        auth_views.PasswordChangeDoneView.as_view(
            template_name="registration/password_change_done.html"
        ),
        name="password_change_done",
    ),
]

urlpatterns = [
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [*urlpatterns, *debug_toolbar_urls()]
  1. 密碼變更頁面
  2. 密碼變更成功頁面

步驟 2:建立密碼變更模板

建立 templates/registration/password_change.html

templates/registration/password_change.html
{% extends "base.html" %}
{% load django_bootstrap5 %}

{% block title %}
  變更密碼 - Django 大冒險
{% endblock title %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-md-6">
      <div class="card">
        <div class="card-header">
          <h2 class="mb-0">變更密碼</h2>
        </div>
        <div class="card-body">
          <form method="post">
            {% csrf_token %}
            {% bootstrap_form form %}  <!-- (1)! -->

            <div class="d-grid gap-2">
              <button type="submit" class="btn btn-primary">變更密碼</button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
{% endblock content %}
  1. PasswordChangeForm 包含三個欄位:舊密碼、新密碼、確認新密碼

建立 templates/registration/password_change_done.html

templates/registration/password_change_done.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-success">
        <h4 class="alert-heading">密碼變更成功!</h4>
        <p>你的密碼已經成功變更。</p>
        <hr />
        <p class="mb-0">
          <a href="{% url 'blog:article_list' %}" class="btn btn-success">回到首頁</a>
        </p>
      </div>
    </div>
  </div>
{% endblock content %}

步驟 3:更新導覽列

回到 templates/base.html,在登入時加入變更密碼連結:

templates/base.html
{% load django_bootstrap5 %}

<!DOCTYPE html>
<html lang="zh-Hant">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Django Playground" />
    <meta name="keywords" content="Django, Playground" />

    <title>
      {% block title %}
        Django 大冒險
      {% endblock title %}
    </title>

    {% bootstrap_css %}

    <link rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" />

    {% block extra_head %}
    {% endblock extra_head %}
  </head>
  <body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
      <div class="container">
        <span class="navbar-brand">Django 大冒險</span>
        <button class="navbar-toggler"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#navbarNav">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav ms-auto">
            <li class="nav-item">
              <a class="nav-link" href="{% url 'blog:article_list' %}">文章列表</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="{% url 'admin:index' %}">管理後台</a>
            </li>
            {% if user.is_authenticated %}
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:password_change' %}">變更密碼</a>  <!-- (1)! -->
              </li>
              <li class="nav-item">
                <form method="post" action="{% url 'auth:logout' %}" class="d-inline">
                  {% csrf_token %}
                  <button type="submit" class="nav-link btn btn-link">登出 ({{ user.username }})</button>
                </form>
              </li>
            {% else %}
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:login' %}">登入</a>
              </li>
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:register' %}">註冊</a>
              </li>
            {% endif %}
          </ul>
        </div>
      </div>
    </nav>

    <main class="container my-4">
      {% bootstrap_messages %}

      {% block content %}
      {% endblock content %}
    </main>

    <footer class="bg-light py-4 mt-5">
      <div class="container text-center">
        <p class="text-muted mb-0">Django Playground</p>
      </div>
    </footer>

    {% bootstrap_javascript %}

    {% block extra_scripts %}
    {% endblock extra_scripts %}
  </body>
</html>
  1. 登入時顯示變更密碼連結

測試密碼變更功能

  1. 確保你已登入
  2. 點擊導覽列的「變更密碼」連結
  3. 填寫表單
  4. 點擊「變更密碼」
  5. 成功後會顯示成功頁面

實作密碼重設功能

密碼重設功能讓使用者在忘記密碼時,可以透過電子郵件重設密碼。

Django 提供了完整的密碼重設流程 views:

步驟 1:設定 Email Backend

在開發環境中,我們使用 file-based email backend,email 會儲存在檔案中而不是真的寄出。

編輯 core/settings.py

core/settings.py
EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"  # (1)!

EMAIL_FILE_PATH = BASE_DIR / "sent_emails"  # (2)!
  1. 使用 file-based email backend
  2. Email 會儲存在專案根目錄的 sent_emails 資料夾

為什麼使用 file-based backend?

在開發環境中:

  • 不需要設定真實的 SMTP 伺服器
  • Email 會儲存在本機檔案中,方便測試
  • 可以直接查看 Email 內容

在生產環境中,需要改用真實的 SMTP 設定。

步驟 2:設定 URL

編輯 core/urls.py,在 auth_urlpatterns 中加入密碼重設 URL:

core/urls.py
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path, reverse_lazy

from core import views

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
    path("register/", views.register, name="register"),
    path(
        "password-change/",
        auth_views.PasswordChangeView.as_view(
            template_name="registration/password_change.html",
            success_url=reverse_lazy("auth:password_change_done"),
        ),
        name="password_change",
    ),
    path(
        "password-change/done/",
        auth_views.PasswordChangeDoneView.as_view(
            template_name="registration/password_change_done.html"
        ),
        name="password_change_done",
    ),
    path(
        "password-reset/",  # (1)!
        auth_views.PasswordResetView.as_view(
            template_name="registration/password_reset.html",
            success_url=reverse_lazy("auth:password_reset_done"),
        ),
        name="password_reset",
    ),
    path(
        "password-reset/done/",  # (2)!
        auth_views.PasswordResetDoneView.as_view(
            template_name="registration/password_reset_done.html"
        ),
        name="password_reset_done",
    ),
    path(
        "password-reset/<uidb64>/<token>/",  # (3)!
        auth_views.PasswordResetConfirmView.as_view(
            template_name="registration/password_reset_confirm.html",
            success_url=reverse_lazy("auth:password_reset_complete"),
        ),
        name="password_reset_confirm",
    ),
    path(
        "password-reset/complete/",  # (4)!
        auth_views.PasswordResetCompleteView.as_view(
            template_name="registration/password_reset_complete.html"
        ),
        name="password_reset_complete",
    ),
]

urlpatterns = [
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [*urlpatterns, *debug_toolbar_urls()]
  1. 密碼重設請求頁面(輸入 email)
  2. 密碼重設請求已送出頁面
  3. 密碼重設確認頁面(設定新密碼)
  4. 密碼重設完成頁面

密碼重設流程

sequenceDiagram
    participant User as 使用者
    participant Web as 網站
    participant Email as Email 系統

    User->>Web: 1. 輸入 email
    Web->>Email: 2. 寄送重設連結
    Web->>User: 3. 顯示「已寄出」頁面
    User->>Email: 4. 查看 email
    Email->>User: 5. 點擊重設連結
    User->>Web: 6. 設定新密碼
    Web->>User: 7. 顯示「重設完成」頁面

步驟 3:建立密碼重設模板

建立 templates/registration/password_reset.html

templates/registration/password_reset.html
{% extends "base.html" %}
{% load django_bootstrap5 %}

{% block title %}
  忘記密碼 - Django 大冒險
{% endblock title %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-md-6">
      <div class="card">
        <div class="card-header">
          <h2 class="mb-0">忘記密碼</h2>
        </div>
        <div class="card-body">
          <p>請輸入你的電子郵件地址,我們會寄送密碼重設連結給你。</p>

          <form method="post">
            {% csrf_token %}
            {% bootstrap_form form %}

            <div class="d-grid gap-2">
              <button type="submit" class="btn btn-primary">送出</button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
{% endblock content %}

建立 templates/registration/password_reset_done.html

templates/registration/password_reset_done.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-info">
        <h4 class="alert-heading">密碼重設信已寄出</h4>
        <p>我們已經將密碼重設連結寄送到你的電子郵件地址。</p>
        <p>請檢查你的信箱,並點擊連結來重設密碼。</p>
        <hr />
        <p class="mb-0 text-muted">
          <small>
            如果沒有收到信件,請檢查垃圾郵件匣。
          </small>
        </p>
      </div>
    </div>
  </div>
{% endblock content %}

建立 templates/registration/password_reset_confirm.html

templates/registration/password_reset_confirm.html
{% extends "base.html" %}
{% load django_bootstrap5 %}

{% block title %}
  設定新密碼 - Django 大冒險
{% endblock title %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-md-6">
      <div class="card">
        <div class="card-header">
          <h2 class="mb-0">設定新密碼</h2>
        </div>
        <div class="card-body">
          {% if validlink %}  <!-- (1)! -->
            <p>請輸入你的新密碼。</p>

            <form method="post">
              {% csrf_token %}
              {% bootstrap_form form %}

              <div class="d-grid gap-2">
                <button type="submit" class="btn btn-primary">設定新密碼</button>
              </div>
            </form>
          {% else %}  <!-- (2)! -->
            <div class="alert alert-danger">
              <h4 class="alert-heading">連結已失效</h4>
              <p>這個密碼重設連結已經失效或已被使用。</p>
              <hr />
              <p class="mb-0">
                <a href="{% url 'auth:password_reset' %}" class="btn btn-danger">重新申請</a>
              </p>
            </div>
          {% endif %}
        </div>
      </div>
    </div>
  </div>
{% endblock content %}
  1. 連結有效時顯示表單
  2. 連結失效時顯示錯誤訊息

建立 templates/registration/password_reset_complete.html

templates/registration/password_reset_complete.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-success">
        <h4 class="alert-heading">密碼重設完成!</h4>
        <p>你的密碼已經成功重設。</p>
        <hr />
        <p class="mb-0">
          <a href="{% url 'auth:login' %}" class="btn btn-success">立即登入</a>
        </p>
      </div>
    </div>
  </div>
{% endblock content %}

步驟 4:更新登入模板

回到 templates/registration/login.html,加入「忘記密碼?」連結:

templates/registration/login.html
{% extends "base.html" %}
{% load django_bootstrap5 %}

{% block title %}
  登入 - Django 大冒險
{% endblock title %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-md-6">
      <div class="card">
        <div class="card-header">
          <h2 class="mb-0">登入</h2>
        </div>
        <div class="card-body">
          <form method="post">
            {% csrf_token %}
            {% bootstrap_form form %}

            <div class="d-grid gap-2">
              <button type="submit" class="btn btn-primary">登入</button>
            </div>
          </form>

          <hr />

          <p class="text-center">
            <a href="{% url 'auth:password_reset' %}">忘記密碼?</a>  <!-- (1)! -->
          </p>

          <p class="text-center mb-0">
            還沒有帳號?<a href="{% url 'auth:register' %}">立即註冊</a>
          </p>
        </div>
      </div>
    </div>
  </div>
{% endblock content %}
  1. 加入密碼重設連結,方便使用者在忘記密碼時重設

步驟 5:建立 Email 樣板

建立檔案 templates/registration/password_reset_email.html 這個檔案會被用來當作忘記密碼信件的內容

templates/registration/password_reset_email.html
Someone asked for password reset for email {{ email }}. Follow the link below:
{{ protocol }}://{{ domain }}{% url 'auth:password_reset_confirm' uidb64=uid token=token %}

測試密碼重設功能

首先,我們需要為測試帳號設定電子郵件。使用 Django shell:

uv run manage.py shell
from django.contrib.auth.models import User

user = User.objects.get(username="test-user")
user.email = "test@example.com"
user.save()
print(f"使用者 {user.username} 的 email 已設定為 {user.email}")

為什麼需要設定 email?

密碼重設功能需要透過 email 寄送重設連結,所以使用者必須有 email。

接下來測試密碼重設功能:

  1. 登出(如果已登入)
  2. 訪問登入頁面,點擊「忘記密碼?」
  3. 輸入電子郵件地址:test@example.com
  4. 點擊「送出」
  5. 看到「密碼重設信已寄出」頁面
  6. 到專案根目錄的 sent_emails 資料夾查看 email
  7. 打開 email 檔案,複製重設連結
  8. 在瀏覽器中訪問該連結
  9. 設定新密碼
  10. 看到「密碼重設完成」頁面
  11. 使用新密碼登入

查看 Email 內容

sent_emails 資料夾中會有一個文字檔,內容類似:

Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: =?utf-8?b?5ZyoIGxvY2FsaG9zdDo4MDAwIOmAsuihjOWvhueivOmHjee9rg==?=
From: webmaster@localhost
To: admin@example.com
Date: Fri, 19 Dec 2025 08:52:13 -0000
Message-ID: <176673913372.70555.6877219139717744732@arthur-mbp14.local>

Someone asked for password reset for email test@example.com. Follow the link below:
http://localhost:8000/auth/password-reset/...

-------------------------------------------------------------------------------

複製 http://127.0.0.1:8000/auth/password-reset/... 這個連結。

保護 Views

現在我們要確保只有登入的使用者才能建立、編輯、刪除文章。

使用 @login_required 裝飾器

Django 提供了 @login_required 裝飾器來保護需要登入才能訪問的 views。

編輯 blog/views.py

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_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})


@login_required
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})


@login_required
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})


@login_required
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. 建立文章需要登入
  2. 編輯文章需要登入
  3. 刪除文章需要登入

@login_required 的作用

當未登入的使用者訪問這些頁面時:

  1. 自動重導向到登入頁面
  2. 登入成功後會自動導回原本要訪問的頁面(使用 next 參數)

測試保護功能

  1. 登出(如果已登入)
  2. 訪問 http://127.0.0.1:8000/blog/articles/create/
  3. 會自動重導向到登入頁面
  4. 登入後會自動導回文章建立頁面

Views 已受保護

現在只有登入的使用者才能:

  • ✅ 建立文章
  • ✅ 編輯文章
  • ✅ 刪除文章

未登入的使用者只能瀏覽文章列表和詳情頁。

任務結束

完成!

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

  • 了解 Django 內建的使用者驗證系統
  • 實作登入功能
  • 實作登出功能
  • 實作註冊功能
  • 實作密碼變更功能
  • 實作密碼重設功能
  • 使用 @login_required 保護 views

你學會了:

  1. Django 的 User Model

    • 內建的使用者系統
    • 使用者欄位和屬性
    • 權限系統概念
  2. 登入與登出

    • 使用 LoginViewLogoutView
    • 自訂登入模板
    • 設定重導向 URL
  3. 使用者註冊

    • 繼承 UserCreationForm
    • 自動登入新使用者
    • 密碼驗證和加密
  4. 密碼管理

    • 密碼變更功能
    • 密碼重設流程(4 個步驟)
    • File-based email backend
  5. 保護 Views

    • 使用 @login_required 裝飾器
    • 自動重導向功能(使用 next 參數)
  6. 全專案共用的設計

    • core/urls.py 中獨立宣告 auth_urlpatterns
    • 使用 registration/ 資料夾存放模板
    • 所有 app 都可以使用這些驗證功能