Django -- 從平凡到超凡

第 12 章    使用者認證

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

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

▸ 設定使用者權限

(1) User Model

∗ 因使用者資料幾乎是所有 Web 系統都需要,因此 Django 內建有 User model, 包含以下欄位:

usernamepasswordemailfirst_name
last_namelast_logingroupsuser_permissions
is_staffis_activeis_superuserdate_joined

▸ 但本專案還需要其他欄位,例如:fullName, website, address 等,習慣上建立 UserProfile model 來儲存額外欄位

∗ 建立新 account app 來處理使用者註冊、登入、及登出事項

▸ Right click project --> Django --> Create application --> Name: account --> OK

▸ 至 blog/settings.pyINSTALLED_APPS 登記

INSTALLED_APPS = [
    ...
    'django.contrib.staticfiles',
    'account',
    'article',
    'main',     
]

▸ 在 blog/urls.py 裡加入 URL 對應,URL 格式為:account/

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^account/', include('account.urls', namespace='account')),
    url(r'^article/', include('article.urls', namespace='article')),
    ...
]

▸ 新增 account/urls.py,內容如下(urlpatterns 暫無內容):

from django.conf.urls import url
from account import views

urlpatterns = [

]

▸ 修改 account/models.py

from django.db import models
from django.contrib.auth.models import User


class UserProfile(models.Model):
    user = models.OneToOneField(User, related_name='profile')
    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.user.username + ')'

▸ 匯入 User model 並加上 UserProfile model

UserProfile model 包含 4 個欄位及 1 個函式:

user
# 對應到 User model, OneToOneField 指定 UserProfileUser 是一對一的關係,一筆 UserProfile 物件對應到一筆 User 物件,一筆 User 物件也對應到一筆 UserProfile 物件,UserProfile 其實可以視為 User 的延伸欄位
# related_name='profile':目前資料關聯方向是從 UserProfileUser,無法反向從 User 連到 UserProfile, 但加上 related_name,即可反向關聯資料,例如:request.user.profile.fullName
fullName:使用者的全名
website:使用者個人網頁,blank=True:在 HTML 表單中可以不輸入資料,null=True:在資料表中可以是空值
address:使用者住址,在 HTML 表單中可以不輸入資料,在資料表中可以是空值
✶ 函式 def __str__(...):顯示使用者全名加帳號名稱

▸ 在 account/admin.py 裡匯入及登記 UserProfile

from django.contrib import admin
from account.models import UserProfile


admin.site.register(UserProfile)

▸ 進行資料庫遷移:

  makemigrations
Migrations for 'account':
    account/migrations/0001_initial.py:
      - Create model UserProfile
Finished "<...>/webapps/git/blog/blog/manage.py makemigrations" execution.

migrate
Operations to perform:
    Apply all migrations: account, admin, article, auth, contenttypes, sessions
Running migrations:
Applying account.0001_initial... OK
Finished "<...>/webapps/git/blog/blog/manage.py migrate" execution.

(2) 訪客註冊

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

▸ 建立 UserFormUserProfileForm 兩個 Django 表單類別

▸ 規劃 URL 對應

▸ 在首頁加上註冊連結

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

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

∗ 建立 Django 表單類別:

account/forms.py

from django import forms
from django.contrib.auth.models import User
from account.models import UserProfile


class UserForm(forms.ModelForm):
    username = forms.CharField(label='帳號')
    password = forms.CharField(label='密碼', widget=forms.PasswordInput())
    password2 = forms.CharField(label='確認密碼', widget=forms.PasswordInput())

    class Meta:
        model = User
        fields = ['username', 'password']

    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(UserForm, self).save(commit=False)
        user.set_password(user.password)
        if commit:
            user.save()
        return user


class UserProfileForm(forms.ModelForm):
    fullName = forms.CharField(label='姓名', max_length=128)
    website = forms.URLField(label='個人網址', max_length=128)
    address = forms.CharField(label='住址', max_length=128)

    class Meta:
        model = UserProfile
        fields = ['fullName', 'website', 'address']

▸ 匯入 forms 以及 User, UserProfile 兩個 model

▸ 使用者表單 UserForm

✶ 使用 User model 的 usernamepassword 欄位,再加一個確認密碼欄位 password2
✶ 兩個密碼欄位都使用 Django 表單所提供的 widget=forms.PasswordInput() 小工具, 讓 Django 自動產生 <input type="password" ...> 標籤, 因此使用者所輸入的密碼不會顯示
class Meta:指定來源 Model 為 User 及所使用欄位為 usernamepassword
def clean_password2(self)
# Django 允許在 Model 裡針對表單欄位內容撰寫「淨化資料」 (Clean data) 程式,另外處理欄位資料, 在此,我們將比對「密碼」及「確認密碼」兩個欄位的資料是否相符,如果不相符就顯示錯誤訊息
# 淨化某個欄位資料的函式名稱為 clean_<fieldName>(self),其中 <fieldName> 是所要處理欄位的名稱,以本例而言,就是 password2
# 表單淨化資料的結構是 Python 的字典結構,因此利用 <variableName> = self.cleaned_data.get('<fieldName>') 指令取出欄位值並指派給變數,此處,我們取出 passwordpassword2 兩欄位的值
# 如果兩個密碼都有值而且並不相同,則利用 raise forms.ValidationError('密碼不相符') 顯示驗證錯誤訊息
# 最後,回覆 password2 值 (即使沒有修改)
def save(self, commit=True)
# 我們希望在表單儲存時,將使用者密碼加密後再儲存,因此撰寫 save() 函式, 此函式另有一個參數 commit,預設值為 True, 用來確認是暫存還是真的存到資料庫,此函式在 views 程式裡會用到
# Django 表單類別 forms.Formforms.ModelForm 都有 save() 函式,因為我們的表單繼承該類別,就可以直接呼叫該函式而執行;但是,如果在我們自己的表單中撰寫 save() 函式就會覆蓋 (Overwrite) 掉父類別函式; 但在此處,我們還是需要執行該函式,此時可以使用 Python 執行父類別函式的指令格式:
super(<ParentClass>, self).<method>()
--> 以本例而言,就是 super(UserForm, self).save()
# user = super(UserForm, self).save(commit=False):執行父類別的 save() 函式,commit=False 表示暫存, 因為還有資料尚未處理,因此不要真正存到資料庫,然後將函式回覆的 Instance 指派給 user 變數
# user.set_password(user.password):利用 set_password() 函式將使用者密碼加密
# if commit: ...:如果確認儲存,就將 user Instance 存到資料庫
# 最後回覆 user Instance

UserProfileForm

✶ 使用 UserProfile model 裡的 3 個欄位:fullNamewebsite、與 address,並指定個別的資料型態
class Meta:說明來源 Model 及所使用的欄位
再談淨化資料

∗ 淨化資料順序

▸ 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 FullnameForm(forms.ModelForm):
    ...

    def clean(self):
        cleanedData = super(ScoreForm, self).clean()
        
        ...   # 修改許多欄位資料,例如:
        cleanedData['fullName'] = cleanedData['lastName'] + cleanedData['firstName']

        ...
        
        return cleanedData

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

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

∗ 規劃 URL 對應

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

account/urls.py:
from django.conf.urls import url
from account import views


urlpatterns = [
    url(r'^register/$', views.register, name='register'),
]

∗ 功能選項加上註冊連結

menu.html:

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

∗ 建立使用者註冊範本

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 }}
  {{ userProfileForm.as_p }}
  <input type="submit" value="送出">
</form>
{% endblock %}

▸ 繼承 base.html 範本,並設定好所需的個區塊內容

▸ 註冊表單:將兩個 Form class userFormuserProfileForm 放在同一個 HTML 表單 <form ...> 標籤中

∗ 撰寫 view 程式

account/views.py

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

from account.forms import UserForm, UserProfileForm


def register(request):
    '''
    Register a new user
    '''
    template = 'account/register.html'
    if request.method == 'GET':
        return render(request, template, {'userForm':UserForm(),
                                          'userProfileForm':UserProfileForm()})
    # POST
    userForm = UserForm(request.POST)
    userProfileForm = UserProfileForm(request.POST)
    if not userForm.is_valid() or not userProfileForm.is_valid():
        return render(request, template, {'userForm':userForm,
                                          'userProfileForm':userProfileForm})
    user = userForm.save()
    userProfile = userProfileForm.save(commit=False)
    userProfile.user = user
    userProfile.save()
    messages.success(request, '歡迎註冊')
    return redirect('main:main')

▸ 匯入:

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

▸ 設定範本為 register.html

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

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

✶ 利用 request.POST 資料分別產生兩個綁定表單 Instance userFormuserProfileForm, 如果使用者輸入的資料有問題,錯誤訊息亦會存在表單內
.is_valie():如果任一表單驗證失敗,回覆綁定表單並自動顯示錯誤訊息
✶ 驗證通過:
# user = userForm.save():呼叫表單的 save() 函式將表單存入資料庫,並將所回覆的 Instance 指派給 user 變數
# userProfile = userProfileForm.save(commit=False):將 userProfileForm 資料暫存並將該物件指派給 userProfile 變數
- commit=False:表示暫存(不要存入資料庫),因為 user 欄位是必填,但目前尚未有資料,存入資料庫會發生錯誤
- userProfile.user = user:設定 userProfileuser 欄位之後再次儲存
# messages.success(...):在使用者成功註冊之後,顯示歡迎字樣
# 最後利用 redirect('main:main') 轉到首頁,完成 Post/redirect/get 機制

▸ 測試:哇啦,訪客可以註冊了!

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

▸ 密碼加密:

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

▸ 那麼,Django 安全嗎?請進入 admin 介面,點「使用者」 Model,再點某個使用者,可以看到:

adminPassword
✶ 「原始密碼並未儲存」:因此管理者無從知道使用者的原始密碼,這就是資訊安全!

▸ 如果開發者惡整,硬是不寫 .set_password() 指令,又如何?

✶ 沒問題,開發者是可以如此做的,我們可以試試看:刪除先前在表單類別裡所寫的 .save() 函式,然後再註冊一個帳號,以下是 admin 介面:
adminPassword2
✶ Django 顯示「無效的密碼格式 ...」,這無關緊要,反正原始密碼並未儲存,沒有安全問題, 但是如此做的話,未來使用者登入時,密碼比對就會失敗,使用者將無法登入,開發者等著客訴吧! ;-)
✶ 由上可知,Django 絕不允許將密碼以明碼儲存,因此 Django 對於資訊安全的控管是非常嚴格的!

(3) 使用者登入

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

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

▸ 實作步驟:

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

∗ 規劃 URL 對應

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

account/urls.py:
...

urlpatterns = [
    url(r'^register/$', views.register, name='register'),
    url(r'^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 type="submit" value="送出"></p>
</form>
{% endblock %}
✶ 繼承 base.html 範本,並設定好所需的個區塊內容
✶ 登入表單:由於欄位很少,我們不使用 Django 表單類別,直接刻 HTML 表單, 兩個輸入欄位:usernamepassword

∗ 撰寫 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, UserProfileForm


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)
    if not user.is_active:
        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:如果驗證失敗(找不到資料相符的使用者),重新顯示網頁並顯示錯誤訊息
if not user.is_active:如果使用者帳號已停用,重新顯示網頁並顯示錯誤訊息
✶ 驗證通過:利用 auth_login 函式將使用者登入, 設定「登入成功」訊息,並轉向首頁,完成 Post/redirect/get 機制

▸ 測試:點擊「登入」連結,可在登入頁面不輸入資料即送出,以觀察錯誤訊息

login --> loginSuccess

(4) 使用者登出

∗ 登出功能

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

▸ 實作步驟:

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

∗ 規劃 URL 對應

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

account/urls.py:
...

urlpatterns = [
    ...
    url(r'^login/$', views.login, name='login'),
    url(r'^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 屬性來判斷使用者是否登入

✶ 如果有登入,顯示登出連結,並顯示使用者名稱 (user.username)
✶ 如果沒有登入,則顯示註冊與登入連結

∗ 撰寫 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 機制
--> 測試:點擊登出
logout --> logoutSuccess

(5) 小結

∗ 上推專案到 Github

▸ Right click project --> Team --> Commit --> Commit message: Chapter 12 finished --> Commit and Push

∗ 練習

▸ 目前 article.htmlarticleRead.html 有許多相似的程式碼,該重構了

上一章       下一章