Django -- 從平凡到超凡

第 11 章    使用者認證

∗ 本章將利用 Django 所提供的使用者認證套件完成以下功能:

▸ 使用者註冊、登入、及登出

▸ 設定使用者權限

(1) 使用者認證功能

▸ 使用者常用的功能有註冊、登入、及登出等,規劃新增一個 account app 來處理:

Right click project Django Create application Name: account OK

▸ 至 INSTALLED_APPS 登記

blog/settings.py
INSTALLED_APPS = [
    ...
    'django.contrib.staticfiles',
    'account',
    'article',
    'main',     
]

▸ 新增 URL 對應檔案 (類似 main/urls.pyurlpatterns 暫無內容):

account/urls.py
from django.urls import path
from account import views

app_name = 'account'
urlpatterns = [

]

▸ 至專案直屬 App 中加入 URL 對應,URL 格式為:account/

blog/urls.py
urlpatterns = [
    path('admin/', admin.site.urls),
    path('account/', include('account.urls', namespace='account')),
    path('article/', include('article.urls', namespace='article')),
    ...
]

∗ Django User model

▸ 所有動態網頁系統都需要使用者的資料與相關功能,因此 Django 內建有 User model 及其相關方法,Model 包含以下欄位:

usernamepasswordemailfirst_name
last_namelast_logingroupsuser_permissions
is_staffis_activeis_superuserdate_joined

∗ 客製化 User model

▸ 如果前述的欄位不敷使用,就應該使用客製化 User model

▸ 本專案將改用客製化 User model,因此原本的資料庫已無法使用,必須重建:

1.  停止伺服器,刪除再重建資料庫 (dropdb, createdb, grant privileges ...)
2.  刪除所有遷移檔案 (00*.py)

▸ 建立客製化 User model:

account/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser


class User(AbstractUser):
    fullName = models.CharField(max_length=128)
    website = models.URLField(blank=True, null=True)
    address = models.CharField(max_length=128, blank=True, null=True)

    def __str__(self):
        return self.fullName + ' (' + self.username + ')'
✶ 匯入 AbstractUser 類別
✶ 建立客製化 User model:繼承 UserAbstract 類別 (註:Django 內建的 User model 也是繼承相同類別),增加 3 個欄位及 1 個函式:
# fullName:使用者的全名
# website:使用者個人網頁,blank=True:在 HTML 表單中可以不輸入資料,null=True:在資料表中可以是空值
# address:使用者住址,在 HTML 表單中可以不輸入資料,在資料表中可以是空值
# 函式 def __str__(...):預設顯示使用者全名加帳號名稱

▸ Django 預設使用內建的 User model,如果改用客製化的 User model,就必須在設定檔裡設定:

blog/settings.py
...
STATIC_URL = '/static/'

AUTH_USER_MODEL = 'account.User'

▸ 資料庫遷移:

✶ Makemigrations: Right click project Django Custom Command Command: makemigrations OK
Migrations for 'article':
  article/migrations/0001_initial.py
    - Create model Article
    - Create model Comment
Migrations for 'account':
  account/migrations/0001_initial.py
    - Create model User
✶ Migrate: Right click project Django Migrate
Operations to perform:
  Apply all migrations: account, admin, article, auth, contenttypes, sessions
Running migrations:
  ...
需要使用客製化 User model 嗎?

Django 強烈建議,如果是新的專案,最好一開始就使用客製化 User model (請至 Django 官網查詢 "Customizing authentication in Django"),這樣未來如果欄位有所更動就會方便許多。

▸ 修改資料填充程式,並重新填入資料:

populate/admin.py
from populate import base
from django.contrib.auth.models import User
from account.models import User


def populate():
    ...
populate/users.py
from populate import base
from django.contrib.auth.models import User
from account.models import User


def populate():
    ...
填入資料:
(blogVenv) $ python -m populate.local
Creating admin account ... done
Creating user accounts ... done
Populating articles and comments ... done

▸ 將 User model 匯入及登記至 admin 頁面:

account/admin.py
from django.contrib import admin
from account.models import User


admin.site.register(User)

▸ 重啟伺服器,並進入管理者頁面,可以看到「使用者」已移至 Account 項目底下

(2) 訪客註冊

∗ 建立訪客註冊功能,步驟如下:

▸ 建立 UserForm 表單類別

▸ 規劃 URL 對應

▸ 在首頁加上註冊連結

▸ 撰寫使用者註冊之 HTML 表單範本

▸ 撰寫 view 程式完成使用者註冊功能

∗ 建立 Django 表單類別:

account/forms.py

from django import forms
from account.models import User


class UserForm(forms.ModelForm):
    username = forms.CharField(label='帳號')
    password = forms.CharField(label='密碼', widget=forms.PasswordInput())
    password2 = forms.CharField(label='確認密碼', widget=forms.PasswordInput())
    fullName = forms.CharField(label='姓名', max_length=128)
    website = forms.URLField(label='個人網址', max_length=128)
    address = forms.CharField(label='住址', max_length=128)
    
    class Meta:
        model = User
        fields = ['username', 'password', 'password2', 'fullName', 'website', 'address']

    def clean_password2(self):
        password = self.cleaned_data.get('password')
        password2 = self.cleaned_data.get('password2')
        if password and password2 and password!=password2:
            raise forms.ValidationError('密碼不相符')
        return password2

    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(user.password)
        if commit:
            user.save()
        return user

▸ 匯入 formsUser model

▸ 使用者表單 UserForm

✶ 使用客製化 User model 的 username, password, fullName, website, 與 address 欄位,另外再加一個確認密碼欄位 password2
✶ 兩個密碼欄位都使用 Django 表單所提供的 widget=forms.PasswordInput() 小工具, 讓 Django 自動產生 <input type="password" ...> 標籤, 因此使用者所輸入的密碼不會顯示
class Meta:指定來源 Model 為 User 及所使用欄位
def clean_password2(self)
# Django 允許在表單類別裡針對欄位內容撰寫「淨化資料」 (Clean data) 程式,用來額外處理欄位資料, 在此,我們將比對「密碼」及「確認密碼」兩個欄位的資料是否相符,如果不相符就顯示錯誤訊息
# 淨化某個欄位資料的函式名稱為 clean_<fieldName>(self),其中 <fieldName> 是所要處理欄位的名稱,以本例而言,就是 password2
# 已淨化的欄位儲存在 self.cleaned_data 屬性中,其結構是 Python 的字典結構,因此利用 <variableName> = self.cleaned_data.get('<fieldName>') 指令取出欄位值並指派給變數,此處,我們取出 passwordpassword2 兩欄位的值
# 如果兩個密碼都有值而且並不相同,則利用 raise forms.ValidationError('密碼不相符') 發出例外錯誤 (Exception) 以顯示驗證錯誤訊息
# 最後,回覆 password2 值 (即使沒有修改)
def save(self, commit=True)
# models.ModelForm 有預設 save() 方法,只要繼承就可以使用 (稱為「父類別的方法」),但我們希望將使用者密碼加密後再儲存,這是額外功能,因此必須撰寫客製化 save() 方法
# save(self, commit=True) 的第二個參數是增加程式彈性,如果只想暫存資料,以 userForm(commit=False) 方式呼叫即可
# 客製化 save() 會覆蓋掉父類別的方法,但我們還是需要先執行原方法來產生實例 (Instance), 然後再將密碼加密,因此採用 user = super().save() 的 Python 執行父類別方法的格式產生實例並指派給 user 變數,然後再利用 user.set_password() 將密碼加密
# if commit: ...:如果確認儲存,就將 user 實例存到資料庫
# 最後回覆 user 實例
資料淨化

∗ 淨化資料順序

▸ Django 表單依欄位順序淨化欄位資料,淨化完畢的資料才會存入 self.cleaned_data 變數中, 因此,如果我們將 clean_password2() 函式名稱改為 clean_password(),將無法取得 password2 欄位資料 (因為尚未淨化);若函式名稱寫成 clean_password2(),則 password 欄位已淨化完畢,因此可以取得

∗ 修改欄位資料

▸ 有時候我們需要自動計算某個欄位的資料,而不是由使用者輸入,這時候就藉由淨化資料來設定,例如:

class FullNameForm(forms.ModelForm):
    lastName = forms.CharField()
    firstName = forms.CharField()
    fullName = forms.CharField()
      
    def clean_fullName(self):
        lastName = self.cleaned_data.get('lastName')
        firstName = self.cleaned_data.get('firstName')
        return lastName + firstName
fullName 的值是另外兩個欄位 lastNamefirstName 的串連 (中文姓名,姓在前),最後 return 的值就是 fullName 的值

∗ 總結淨化:def clean(self)

▸ 如果需要額外處理的欄位很多,寫許多 def clean_<fieldName>(self) 函式並不方便,可以撰寫 clean() 函式,一次處理完畢:

class PriceForm(forms.ModelForm):
    ...

    def clean(self):
        cleanedData = super().clean()
        # 取得許多欄位資料,例如:
        cleanedData['total'] = cleanedData['price'] * cleanedData['amount']
        return cleanedData

▸ 自行撰寫的 clean() 函式也會覆蓋父類別的 clean() 函式, 但我們還是需要利用父類別的函式來驗證所有資料,因此先執行 super().clean() 指令

▸ 然後設定各個欄位的資料,最後回覆 cleanedData 變數來修改表單的 self.cleaned_data 屬性

∗ 撰寫 view 程式

account/views.py

from django.shortcuts import render, redirect
from django.contrib import messages

from account.forms import UserForm


def register(request):
    '''
    Register a new user
    '''
    template = 'account/register.html'
    if request.method == 'GET':
        return render(request, template, {'userForm':UserForm()})

    # POST
    userForm = UserForm(request.POST)
    if not userForm.is_valid():
        return render(request, template, {'userForm':userForm})

    userForm.save()
    messages.success(request, '歡迎註冊')
    return redirect('main:main')

▸ 匯入:

redirect:以便完成 Post/Redirect/Get 機制
messages:讓訊息可以傳到頁面顯示,不必透過範本變數
UserForm:使用者表單類別

▸ 設定範本為 register.html

▸ 如果是 GET 方法,表示訪客按下註冊連結,因此產生未綁定表單 Instance UserForm() 讓訪客填寫註冊資料

▸ 如果是 POST 方法,表示訪客已輸入註冊資料並送出:

✶ 利用 request.POST 資料產生綁定表單 Instance userForm 如果使用者輸入的資料有問題,錯誤訊息亦會存在表單內
.is_valie():如果表單驗證失敗,回覆綁定表單並自動顯示錯誤訊息
✶ 驗證通過:
# user = userForm.save():呼叫表單的 save() 函式將表單存入資料庫 (密碼加密會在表單類別裡執行)
# messages.success(...):在使用者成功註冊之後,顯示歡迎字樣
# 最後利用 redirect('main:main') 轉到首頁,完成 Post/redirect/get 機制

∗ 規劃 URL 對應

▸ 規劃註冊之 URL 格式為 account/register/

account/urls.py
from django.urls import path
from account import views


urlpatterns = [
    path('register/', views.register, name='register'),
]

∗ 功能選項加上註冊連結

main/templates/main/menu.html

<ul id="menu">
  ...
  <li><a href="{% url 'article:article' %}">部落格</a></li>
  <li><a href="{% url 'account:register' %}">註冊</a></li>
</ul>

∗ 建立使用者註冊範本

▸ 先新增 account/templatesaccount/templates/account 兩目錄,再新增以下檔案:

account/templates/account/register.html

{% extends "main/base.html" %}
{% block heading %}註冊{% endblock %}
{% block content %}
<form method="post" action="{% url 'account:register' %}">
  {% csrf_token %}
  {{ userForm.as_p }}
  <input class="btn" type="submit" value="送出">
</form>
{% endblock %}
✶ 繼承 base.html 範本,並設定好所需的個區塊內容
✶ 註冊表單:將 Form class userForm 放在 HTML 表單 <form ...> 標籤中

▸ 測試:在註冊頁面填入相關資料,並按下送出按鈕,哇啦,訪客可以註冊了!

註冊連結
註冊表單
歡迎註冊
有關密碼加密

▸ 密碼加密:

✶ 密碼加密是開發者最基本的專業 (應該說是「敬業」), 曾經見過有開發者居然將密碼原封不動地直接以明碼 (Cleartext) 儲存到資料庫!真是令人吐血!!
✶ 密碼是一切資訊安全的基礎,除了自己以外絕對不能讓任何人知道,包括系統管理者,因此, 系統管理者只能 重設 使用者的密碼,不能 看到 使用者的密碼,如果密碼以明碼方式儲存到資料庫, 那系統管理者就可以看到所有人的密碼,也就能以任何用者的身份登入系統了
✶ 在台灣還是看得到在某網站註冊後,網站回覆電郵訊息是:「歡迎您註冊, 您的帳號是:...,您的密碼是:...」,直接告知你的密碼?天哪!

▸ 進入 admin 頁面,點「使用者」 Model,再點某個使用者,可以看到:

adminPassword
✶ 密碼欄位的內容是一長串亂數,因此管理者無從知道使用者的原始密碼

(3) 使用者登入

∗ 使用者註冊成功後即可登入網站

▸ Django 提供 authenticate()login() 兩函式, 讓撰寫登入程式變得非常簡單

▸ 實作步驟:

✶ 撰寫 view 程式
✶ 規劃 URL 對應
✶ 在首頁加入登入連結
✶ 撰寫登入範本

∗ 撰寫 view 程式

account/views.py

from django.shortcuts import render, redirect
from django.contrib import messages
from django.contrib.auth import authenticate
from django.contrib.auth import login as auth_login

from account.forms import UserForm


def register(request):
    ...


def login(request):
    '''
    Login an existing user
    '''
    template = 'account/login.html'
    if request.method == 'GET':
        return render(request, template)

    # POST
    username = request.POST.get('username')
    password = request.POST.get('password')
    if not username or not password:    # Server-side validation
        messages.error(request, '請填資料')
        return render(request, template)

    user = authenticate(username=username, password=password)
    if not user:    # authentication fails
        messages.error(request, '登入失敗')
        return render(request, template)

    # login success
    auth_login(request, user)
    messages.success(request, '登入成功')
    return redirect('main:main')

▸ 匯入 authenticatelogin 模組,並將 login 模組名稱改為 auth_login, 因為我們將要撰寫登入函式,而且想要稱此函式為 login, 因此將 Django 函式改名! ;-)

▸ 如果 Request 的方法是 GET:表示使用者點選登入連結準備登入,因此顯示 login.html 網頁, 因為登入程序並不會儲存資料,因此不需要 Django 表單類別,直接刻 HTML 表單就行

▸ 如果是 POST 方法:表示使用者已輸入登入資料並送出表單

✶ 利用 request.POST.get(...) 擷取 HTML 表單的 usernamepassword 欄位資料,並執行伺服器端驗證,確認使用者已輸入資料
✶ 如果在 HTML 表單中找不到該變數,request.GET.get() 函式會回覆 None,因此也可以寫成 request.GET.get('<key>', None)
✶ 利用 Django 的 authenticate() 函式驗證使用者帳號及密碼
if not user:如果驗證失敗(找不到資料相符的使用者),重新顯示網頁並顯示錯誤訊息
✶ 驗證通過:利用 auth_login 函式將使用者登入, 設定「登入成功」訊息,並轉向首頁,完成 Post/redirect/get 機制

∗ 規劃 URL 對應

▸ 規劃使用者登入的 URL 格式為 account/login/

account/urls.py
...

urlpatterns = [
    path('register/', views.register, name='register'),
    path('login/', views.login, name='login'),
]

∗ 功能選項加上註冊連結

main/templates/main/menu.html

<ul id="menu">
  ...
  <li><a href="{% url 'account:register' %}">註冊</a></li>
  <li><a href="{% url 'account:login' %}">登入</a></li>
</ul>

∗ 撰寫登入頁面

▸ 新增登入頁面檔案

account/templates/account/login.html
{% extends "main/base.html" %}
{% block heading %}登入{% endblock %}
{% block content %}
<form method="post" action="{% url 'account:login' %}">
  {% csrf_token %}
  <p>使用者名稱:<input type="text" name="username"></p>
  <p>密碼:<input type="password" name="password"></p>
  <p><input class="btn" type="submit" value="送出"></p>
</form>
{% endblock %}
✶ 繼承 base.html 範本,並設定好所需的個區塊內容
✶ 登入表單:由於欄位很少,我們不使用 Django 表單類別,直接刻 HTML 表單, 兩個輸入欄位:usernamepassword

▸ 測試:點擊「登入」連結,不輸入資料即送出,會有錯誤訊息

login

▸ 資料輸入正確,即可登入

loginSuccess

(4) 使用者登出

∗ 登出功能

▸ Django 提供 logout() 函式,讓撰寫登出程式變得非常簡單

▸ 實作步驟:

✶ 撰寫登出 view 程式
✶ 規劃 URL 對應
✶ 在首頁加入登出連結

∗ 撰寫 view 程式

account/views.py

...
from django.contrib.auth import login as auth_login
from django.contrib.auth import logout as auth_logout

from account.forms import UserForm, UserProfileForm


def login(request):
    ...


def logout(request):
    '''
    Logout the user
    '''
    auth_logout(request)
    messages.success(request, '歡迎再度光臨')
    return redirect('main:main')

▸ 匯入 logout 模組並將名稱改為 auth_logout (因為我們喜歡 logout 函式名稱 ;-)

▸ 利用 auth_logout() 函式將使用者登出

▸ 設定顯示訊息

▸ 最後轉向到首頁,完成 Post/redirect/get 機制

∗ 規劃 URL 對應

▸ 規劃登出之 URL 格式為 account/logout/

account/urls.py
...

urlpatterns = [
    ...
    path('login/', views.login, name='login'),
    path('logout/', views.logout, name='logout'),
]

∗ 在首頁加入登出連結

main/templates/main/menu.html

...
  <li><a href="{% url 'article:article' %}">部落格</a></li>
  {% if user.is_authenticated %}
    <li><a href="{% url 'account:logout' %}">登出 ({{ user.username }})</a></li>
  {% else %}
    <li><a href="{% url 'account:register' %}">註冊</a></li>
    <li><a href="{% url 'account:login' %}">登入</a></li>
  {% endif %}
</ul>

▸ 利用 Django 範本引擎提供的範本變數 useris_authenticated 屬性來判斷使用者是否登入,在 settings.py 設定檔中,有以下程式碼,這是 Django 範本引擎的相關設定,其中的 django.contrib.auth.context_processors.auth 就是已將認證功能加入,因此直接在範本中可以取得 user 物件,不需要從 views 程式傳入

TEMPLATES = [
   ...
            'context_processors': [
                ...
                'django.contrib.auth.context_processors.auth',
                ...
            ],
   ...
]
✶ 如果有登入,顯示登出連結,並顯示使用者名稱 (user.username)
✶ 如果沒有登入,則顯示註冊與登入連結

▸ 測試:點擊登出

logout

logoutSuccess

∗ 上推專案到 Github

... Commit message: Chapter 11 finished Commit and Push

▸ 練習