我們繼續來完成上一章節中的項目,實現“用戶註冊”和“用戶登錄”的功能,並限制只有登錄的用戶才能爲老師投票。Django框架中提供了對錶單的封裝,而且提供了多種不同的使用方式。
首先添加用戶模型。
class User(models.Model):
"""用戶"""
no = models.AutoField(primary_key=True, verbose_name='編號')
username = models.CharField(max_length=20, unique=True, verbose_name='用戶名')
password = models.CharField(max_length=32, verbose_name='密碼')
regdate = models.DateTimeField(auto_now_add=True, verbose_name='註冊時間')
class Meta:
db_table = 'tb_user'
verbose_name_plural = '用戶'
通過生成遷移和執行遷移操作,在數據庫中創建對應的用戶表。
(venv)$ python manage.py makemigrations vote
...
(venv)$ python manage.py migrate
...
定製一個非常簡單的註冊模板頁面。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>用戶註冊</title>
<style>/* 此處省略層疊樣式表選擇器 */</style>
</head>
<body>
<h1>用戶註冊</h1>
<hr>
<p class="hint">{{ hint }}</p>
<form action="/register/" method="post">
{% csrf_token %}
<div class="input">
<label for="username">用戶名:</label>
<input type="text" id="username" name="username">
</div>
<div class="input">
<label for="password">密碼:</label>
<input type="password" id="password" name="password">
</div>
<div class="input">
<label for="repassword">確認密碼:</label>
<input type="password" id="repassword" name="repassword">
</div>
<div class="input">
<input type="submit" value="註冊">
<input type="reset" value="重置">
</div>
</form>
<a href="/login">返回登錄</a>
</body>
</html>
注意,在上面的表單中,我們使用了模板指令{% csrf_token %}
爲表單添加一個隱藏域(type屬性值爲hidden的input標籤),它的作用是在表單中生成一個隨機令牌(token)來防範跨站請求僞造(通常簡稱爲CSRF),這也是Django在提交表單時的硬性要求,除非我們設置了免除CSRF令牌。下圖是一個關於CSRF簡單生動的例子,它來自於維基百科。
用戶在提交註冊表單時,我們還需要對用戶的輸入進行驗證,例如我們的網站要求用戶名必須由字母、數字、下劃線構成且長度在4-20個字符之間,密碼的長度爲8-20個字符,確認密碼必須跟密碼保持一致。這些驗證操作首先可以通過瀏覽器中的JavaScript代碼來完成,但是即便如此,在服務器端仍然要對用戶輸入再次進行驗證來避免將無效的數據庫交給數據庫,因爲用戶可能會禁用瀏覽器的JavaScript功能,也有可能繞過瀏覽器的輸入檢查將註冊數據提交給服務器,所以服務器端的用戶輸入檢查仍然是必要的。
我們可以利用Django框架封裝的表單功能來對用戶輸入的有效性進行檢查,雖然Django封裝的表單還能幫助我們定製出頁面上的表單元素,但這顯然是一種靈活性很差的設計,這樣的功能在實際開發中基本不考慮,所以表單主要的作用就在於數據驗證,具體的做法如下所示。
USERNAME_PATTERN = re.compile(r'\w{4,20}')
class RegisterForm(forms.ModelForm):
repassword = forms.CharField(min_length=8, max_length=20)
def clean_username(self):
username = self.cleaned_data['username']
if not USERNAME_PATTERN.fullmatch(username):
raise ValidationError('用戶名由字母、數字和下劃線構成且長度爲4-20個字符')
return username
def clean_password(self):
password = self.cleaned_data['password']
if len(password) < 8 or len(password) > 20:
raise ValidationError('無效的密碼,密碼長度爲8-20個字符')
return to_md5_hex(self.cleaned_data['password'])
def clean_repassword(self):
repassword = to_md5_hex(self.cleaned_data['repassword'])
if repassword != self.cleaned_data['password']:
raise ValidationError('密碼和確認密碼不一致')
return repassword
class Meta:
model = User
exclude = ('no', 'regdate')
上面,我們定義了一個與User模型綁定的表單(繼承自ModelForm),我們排除了用戶編號(no)和註冊日期(regdate)這兩個屬性,並添加了一個repassword屬性用來接收從用戶表單傳給服務器的確認密碼。我們在定義User模型時已經對用戶名的最大長度進行了限制,上面我們又對確認密碼的最小和最大長度進行了限制,但是這些都不足以完成我們對用戶輸入的驗證。上面以clean_
打頭的方法就是我們自定義的驗證規則。很明顯,clean_username
是對用戶名的檢查,而clean_password
是對密碼的檢查。由於數據庫二維表中不應該保存密碼的原文,所以對密碼做了一個簡單的MD5摘要處理,實際開發中如果只做出這樣的處理還不太夠,因爲即便使用了摘要,仍然有利用彩虹表反向查詢破解用戶密碼的風險,如何做得更好我們會在後續的內容中講到。爲字符串生成MD5摘要的代碼如下所示。
def to_md5_hex(message):
return hashlib.md5(message.encode()).hexdigest()
新增一個視圖函數實現用戶註冊的功能。
def register(request):
page, hint = 'register.html', ''
if request.method == 'POST':
form = RegisterForm(request.POST)
if form.is_valid():
form.save()
page = 'login.html'
hint = '註冊成功,請登錄'
else:
hint = '請輸入有效的註冊信息'
return render(request, page, {'hint': hint})
如果用戶發起GET請求,將直接跳轉到註冊的頁面;如果用戶以POST方式提交註冊表單,則創建自定義的註冊表單對象並獲取用戶輸入。可以通過表單對象的is_valid
方法對錶單進行驗證,如果用戶輸入沒有問題,該方法返回True,否則返回False;由於我們定義的RegisterForm繼承自ModelForm,因此也可以直接使用表單對象的save
方法來保存模型。下面是註冊請求的URL配置。
from django.contrib import admin
from django.urls import path
from vote import views
urlpatterns = [
# 此處省略上面的代碼
path('register/', views.register, name='register'),
# 此處省略下面的代碼
]
說明:
path
函數可以通過name參數給URL綁定一個逆向解析的名字,也就是說,如果需要可以從後面給的名字逆向解析出對應的URL。
我們再來定製一個非常簡單的登錄頁。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>用戶登錄</title>
<style>/* 此處省略層疊樣式表選擇器 */</style>
</head>
<body>
<h1>用戶登錄</h1>
<hr>
<p class="hint">{{ hint }}</p>
<form action="/login/" method="post">
{% csrf_token %}
<div class="input">
<label for="username">用戶名:</label>
<input type="text" id="username" name="username">
</div>
<div class="input">
<label for="password">密碼:</label>
<input type="password" id="password" name="password">
</div>
<div class="input captcha">
<label for="captcha">驗證碼:</label>
<input type="text" id="captcha" name="captcha">
<img src="/captcha/" width="120">
</div>
<div class="input">
<input type="submit" value="登錄">
<input type="reset" value="重置">
</div>
</form>
<a href="/register">註冊新用戶</a>
</body>
</html>
上面的登錄頁中,我們要求用戶提供驗證碼,驗證碼全稱是全自動區分計算機和人類的公開圖靈測試,它是一種用來區分系統的使用者是計算機還是人類的程序。簡單的說就是程序出一個只有人類能夠回答的問題,由系統使用者來解答,由於計算機理論上無法解答程序提出的問題,所以回答出問題的用戶就可以被認爲是人類。大多數的網站都使用了不同類型的驗證碼技術來防範用程序自動註冊用戶或模擬用戶登錄(暴力破解用戶密碼),因爲驗證碼具有一次消費性,而沒有通過圖靈測試的程序是不能夠完成註冊或登錄的。
在Python程序中生成驗證碼並不算特別複雜,但需要三方庫Pillow的支持(PIL的分支),因爲要對驗證碼圖片進行旋轉、扭曲、拉伸以及加入干擾信息來防範那些用OCR(光學文字識別)破解驗證碼的程序。下面的代碼封裝了生成驗證碼圖片的功能,大家可以直接用這些代碼來生成圖片驗證碼,不要“重複發明輪子”。
"""
圖片驗證碼
"""
import os
import random
from io import BytesIO
from PIL import Image
from PIL import ImageFilter
from PIL.ImageDraw import Draw
from PIL.ImageFont import truetype
class Bezier(object):
"""貝塞爾曲線"""
def __init__(self):
self.tsequence = tuple([t / 20.0 for t in range(21)])
self.beziers = {}
def make_bezier(self, n):
"""繪製貝塞爾曲線"""
try:
return self.beziers[n]
except KeyError:
combinations = pascal_row(n - 1)
result = []
for t in self.tsequence:
tpowers = (t ** i for i in range(n))
upowers = ((1 - t) ** i for i in range(n - 1, -1, -1))
coefs = [c * a * b for c, a, b in zip(combinations,
tpowers, upowers)]
result.append(coefs)
self.beziers[n] = result
return result
class Captcha(object):
"""驗證碼"""
def __init__(self, width, height, fonts=None, color=None):
self._image = None
self._fonts = fonts if fonts else \
[os.path.join(os.path.dirname(__file__), 'fonts', font)
for font in ['ArialRB.ttf', 'ArialNI.ttf', 'Georgia.ttf', 'Kongxin.ttf']]
self._color = color if color else random_color(0, 200, random.randint(220, 255))
self._width, self._height = width, height
@classmethod
def instance(cls, width=200, height=75):
prop_name = f'_instance_{width}_{height}'
if not hasattr(cls, prop_name):
setattr(cls, prop_name, cls(width, height))
return getattr(cls, prop_name)
def background(self):
"""繪製背景"""
Draw(self._image).rectangle([(0, 0), self._image.size],
fill=random_color(230, 255))
def smooth(self):
"""平滑圖像"""
return self._image.filter(ImageFilter.SMOOTH)
def curve(self, width=4, number=6, color=None):
"""繪製曲線"""
dx, height = self._image.size
dx /= number
path = [(dx * i, random.randint(0, height))
for i in range(1, number)]
bcoefs = Bezier().make_bezier(number - 1)
points = []
for coefs in bcoefs:
points.append(tuple(sum([coef * p for coef, p in zip(coefs, ps)])
for ps in zip(*path)))
Draw(self._image).line(points, fill=color if color else self._color, width=width)
def noise(self, number=50, level=2, color=None):
"""繪製擾碼"""
width, height = self._image.size
dx, dy = width / 10, height / 10
width, height = width - dx, height - dy
draw = Draw(self._image)
for i in range(number):
x = int(random.uniform(dx, width))
y = int(random.uniform(dy, height))
draw.line(((x, y), (x + level, y)),
fill=color if color else self._color, width=level)
def text(self, captcha_text, fonts, font_sizes=None, drawings=None, squeeze_factor=0.75, color=None):
"""繪製文本"""
color = color if color else self._color
fonts = tuple([truetype(name, size)
for name in fonts
for size in font_sizes or (65, 70, 75)])
draw = Draw(self._image)
char_images = []
for c in captcha_text:
font = random.choice(fonts)
c_width, c_height = draw.textsize(c, font=font)
char_image = Image.new('RGB', (c_width, c_height), (0, 0, 0))
char_draw = Draw(char_image)
char_draw.text((0, 0), c, font=font, fill=color)
char_image = char_image.crop(char_image.getbbox())
for drawing in drawings:
d = getattr(self, drawing)
char_image = d(char_image)
char_images.append(char_image)
width, height = self._image.size
offset = int((width - sum(int(i.size[0] * squeeze_factor)
for i in char_images[:-1]) -
char_images[-1].size[0]) / 2)
for char_image in char_images:
c_width, c_height = char_image.size
mask = char_image.convert('L').point(lambda i: i * 1.97)
self._image.paste(char_image,
(offset, int((height - c_height) / 2)),
mask)
offset += int(c_width * squeeze_factor)
@staticmethod
def warp(image, dx_factor=0.3, dy_factor=0.3):
"""圖像扭曲"""
width, height = image.size
dx = width * dx_factor
dy = height * dy_factor
x1 = int(random.uniform(-dx, dx))
y1 = int(random.uniform(-dy, dy))
x2 = int(random.uniform(-dx, dx))
y2 = int(random.uniform(-dy, dy))
warp_image = Image.new(
'RGB',
(width + abs(x1) + abs(x2), height + abs(y1) + abs(y2)))
warp_image.paste(image, (abs(x1), abs(y1)))
width2, height2 = warp_image.size
return warp_image.transform(
(width, height),
Image.QUAD,
(x1, y1, -x1, height2 - y2, width2 + x2, height2 + y2, width2 - x2, -y1))
@staticmethod
def offset(image, dx_factor=0.1, dy_factor=0.2):
"""圖像偏移"""
width, height = image.size
dx = int(random.random() * width * dx_factor)
dy = int(random.random() * height * dy_factor)
offset_image = Image.new('RGB', (width + dx, height + dy))
offset_image.paste(image, (dx, dy))
return offset_image
@staticmethod
def rotate(image, angle=25):
"""圖像旋轉"""
return image.rotate(random.uniform(-angle, angle),
Image.BILINEAR, expand=1)
def generate(self, captcha_text='', fmt='PNG'):
"""生成驗證碼(文字和圖片)"""
self._image = Image.new('RGB', (self._width, self._height), (255, 255, 255))
self.background()
self.text(captcha_text, self._fonts,
drawings=['warp', 'rotate', 'offset'])
self.curve()
self.noise()
self.smooth()
image_bytes = BytesIO()
self._image.save(image_bytes, format=fmt)
return image_bytes.getvalue()
def pascal_row(n=0):
"""生成Pascal三角第n行"""
result = [1]
x, numerator = 1, n
for denominator in range(1, n // 2 + 1):
x *= numerator
x /= denominator
result.append(x)
numerator -= 1
if n & 1 == 0:
result.extend(reversed(result[:-1]))
else:
result.extend(reversed(result))
return result
def random_color(start=0, end=255, opacity=255):
"""獲得隨機顏色"""
red = random.randint(start, end)
green = random.randint(start, end)
blue = random.randint(start, end)
if opacity is None:
return red, green, blue
return red, green, blue, opacity
說明:上面的代碼在生成驗證碼圖片時用到了三種字體文件,使用上面的代碼時需要添加字體文件到應用目錄下的fonts目錄中。
下面的視圖函數用來生成驗證碼並通過HttpResponse對象輸出到用戶瀏覽器中。
ALL_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
def get_captcha_text(length=4):
selected_chars = random.choices(ALL_CHARS, k=length)
return ''.join(selected_chars)
def get_captcha(request):
"""獲得驗證碼"""
captcha_text = get_captcha_text()
image = Captcha.instance().generate(captcha_text)
return HttpResponse(image, content_type='image/png')
生成的驗證碼如下圖所示。
爲了驗證用戶提交的登錄表單,我們再定義個表單類。
class LoginForm(forms.Form):
username = forms.CharField(min_length=4, max_length=20)
password = forms.CharField(min_length=8, max_length=20)
captcha = forms.CharField(min_length=4, max_length=4)
def clean_username(self):
username = self.cleaned_data['username']
if not USERNAME_PATTERN.fullmatch(username):
raise ValidationError('無效的用戶名')
return username
def clean_password(self):
return to_md5_hex(self.cleaned_data['password'])
跟之前我們定義的註冊表單類略有區別,登錄表單類直接繼承自Form沒有跟模型綁定,定義了三個字段分別對應登錄表單中的用戶名、密碼和驗證碼。接下來是處理用戶登錄的視圖函數。
def login(request):
hint = ''
if request.method == 'POST':
form = LoginForm(request.POST)
if form.is_valid():
username = form.cleaned_data['username']
password = form.cleaned_data['password']
user = User.objects.filter(username=username, password=password).first()
if user:
return redirect('/')
else:
hint = '用戶名或密碼錯誤'
else:
hint = '請輸入有效的登錄信息'
return render(request, 'login.html', {'hint': hint})
映射URL。
from django.contrib import admin
from django.urls import path
from vote import views
urlpatterns = [
# 此處省略上面的代碼
path('login/', views.login, name='login'),
# 此處省略下面的代碼
]
需要指出,上面我們設定用戶登錄成功時直接返回首頁,而且在用戶登錄時並沒有驗證用戶輸入的驗證碼是否正確,這些我們留到下一個單元再爲大家講解。另外,如果要在Django自帶的管理後臺中進行表單驗證,可以在admin.py的模型管理類中指定form
屬性爲自定義的表單即可,例如:
class UserForm(forms.ModelForm):
password = forms.CharField(min_length=8, max_length=20,
widget=forms.PasswordInput, label='密碼')
def clean_username(self):
username = self.cleaned_data['username']
if not USERNAME_PATTERN.fullmatch(username):
raise ValidationError('用戶名由字母、數字和下劃線構成且長度爲4-20個字符')
return username
def clean_password(self):
password = self.cleaned_data['password']
return to_md5_hex(self.cleaned_data['password'])
class Meta:
model = User
exclude = ('no', )
class UserAdmin(admin.ModelAdmin):
list_display = ('no', 'username', 'password', 'email', 'tel')
ordering = ('no', )
form = UserForm
list_per_page = 10
admin.site.register(User, UserAdmin)