提交 27474271 编写于 作者: J jackfrued

更新了部分文档

上级 04604252
......@@ -2,18 +2,164 @@
我们继续来完成上一章节中的项目,实现“用户登录”的功能,并限制只有登录的用户才能投票。
### 实现用户登录
### 用户登录的准备工作
我们先为实现用户登录做一些准备工作。
1. 创建用户模型。之前我们讲解过如果通过Django的ORM实现从二维表到模型的转换(反向工程),这次我们尝试把模型变成二维表(正向工程)。
```Python
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='密码')
tel = models.CharField(max_length=20, verbose_name='手机号')
reg_date = models.DateTimeField(auto_now_add=True, verbose_name='注册时间')
last_visit = models.DateTimeField(null=True, verbose_name='最后登录时间')
class Meta:
db_table = 'tb_user'
verbose_name = '用户'
verbose_name_plural = '用户'
```
2. 使用下面的命令生成迁移文件并执行迁移,将`User`模型直接变成关系型数据库中的二维表`tb_user`
```Bash
python manage.py makemigrations polls
python manage.py migrate polls
```
3. 用下面的SQL语句直接插入两条测试数据,通常不能讲用户的密码直接保存在数据库中,因此我们将用户密码处理成对应的MD5摘要。MD5消息摘要算法是一种被广泛使用的密码哈希函数(散列函数),可以产生出一个128位(比特)的哈希值(散列值),用于确保信息传输完整一致。在使用哈希值时,通常会将哈希值表示为16进制字符串,因此128位的MD5摘要通常表示为32个十六进制符号。
4. 编写用户登录的模板页。
```SQL
insert into `tb_user`
(`username`, `password`, `tel`, `reg_date`)
values
('wangdachui', '1c63129ae9db9c60c3e8aa94d3e00495', '13122334455', now()),
('hellokitty', 'c6f8cf68e5f68b0aa4680e089ee4742c', '13890006789', now());
```
> **说明**:上面创建的两个用户`wangdachui`和`hellokitty`密码分别是`1qaz2wsx`和`Abc123!!`。
4. 我们在应用下增加一个名为`utils.py`的模块用来保存需要使用的工具函数。Python标准库中的`hashlib`模块封装了常用的哈希算法,包括:MD5、SHA1、SHA256等。下面是使用`hashlib`中的`md5`类将字符串处理成MD5摘要的函数如下所示。
```Python
import hashlib
def gen_md5_digest(content):
return hashlib.md5(content.encode()).hexdigest()
```
5. 编写用户登录的视图函数和模板页。
添加渲染登录页面的视图函数:
```Python
def login(request: HttpRequest) -> HttpResponse:
hint = ''
return render(request, 'login.html', {'hint': hint})
```
增加`login.html`模板页:
```HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>用户登录</title>
<style>
#container {
width: 520px;
margin: 10px auto;
}
.input {
margin: 20px 0;
width: 460px;
height: 40px;
}
.input>label {
display: inline-block;
width: 140px;
text-align: right;
}
.input>img {
width: 150px;
vertical-align: middle;
}
input[name=captcha] {
vertical-align: middle;
}
form+div {
margin-top: 20px;
}
form+div>a {
text-decoration: none;
color: darkcyan;
font-size: 1.2em;
}
.button {
width: 500px;
text-align: center;
margin-top: 20px;
}
.hint {
color: red;
font-size: 12px;
}
</style>
</head>
<body>
<div id="container">
<h1>用户登录</h1>
<hr>
<p class="hint">{{ hint }}</p>
<form action="/login/" method="post">
{% csrf_token %}
<fieldset>
<legend>用户信息</legend>
<div class="input">
<label>用户名:</label>
<input type="text" name="username">
</div>
<div class="input">
<label>密码:</label>
<input type="password" name="password">
</div>
<div class="input">
<label>验证码:</label>
<input type="text" name="captcha">
<img id="code" src="/captcha/" alt="" width="150" height="40">
</div>
</fieldset>
<div class="button">
<input type="submit" value="登录">
<input type="reset" value="重置">
</div>
</form>
<div>
<a href="/">返回首页</a>
<a href="/register/">注册新用户</a>
</div>
</div>
</body>
</html>
```
注意,在上面的表单中,我们使用了模板指令`{% csrf_token %}`为表单添加一个隐藏域(大家可以在浏览器中显示网页源代码就可以看到这个指令生成的`type`属性为`hidden`的`input`标签),它的作用是在表单中生成一个随机令牌(token)来防范[跨站请求伪造](<https://zh.wikipedia.org/wiki/%E8%B7%A8%E7%AB%99%E8%AF%B7%E6%B1%82%E4%BC%AA%E9%80%A0>)(简称为CSRF),这也是Django在提交表单时的硬性要求。如果我们的表单中没有这样的令牌,那么提交表单时,Django框架会产生一个响应状态码为`403`的响应(禁止访问),除非我们设置了免除CSRF令牌。下图是一个关于CSRF简单生动的例子。
![](./res/csrf-simple.png)
接下来,我们可以编写提供验证码和实现用户登录的视图函数,在此之前,我们先说说一个Web应用实现用户跟踪的方式以及Django框架对实现用户跟踪所提供的支持。对一个Web应用来说,用户登录成功后必然要让服务器能够记住该用户已经登录,这样服务器才能为这个用户提供更好的服务,而且上面说到的CSRF也是通过钓鱼网站来套取用户登录信息进行恶意操作的攻击手段,这些都是以用户跟踪技术为基础的。在理解了这些背景知识后,我们就清楚用户登录时到底需要执行哪些操作。
### 实现用户跟踪
如今,一个网站如果不通过某种方式记住你是谁以及你之前在网站的活动情况,失去的就是网站的可用性和便利性,继而很有可能导致网站用户的流式,所以记住一个用户(更专业的说法叫**用户跟踪**)对绝大多数Web应用来说都是必需的功能。
在服务器端,我们想记住一个用户最简单的办法就是创建一个对象,通过这个对象就可以把用户相关的信息都保存起来,这个对象就是我们常说的session(用户会话对象)。那么问题来了,HTTP本身是一个无连接(每次请求和响应的过程中,服务器一旦完成对客户端请求的响应之后就断开连接)、无状态(客户端再次发起对服务器的请求时,服务器无法得知这个客户端之前的任何信息)的协议,即便服务器通过session对象保留了用户数据,还得通过某种方式来确定当前的请求与之前保存过的哪一个session是有关联的。相信很多人都能想到,我们可以给每个session对象分配一个全局唯一的标识符来识别session对象,我们姑且称之为sessionid,每次客户端发起请求时,只要携带上这个sessionid,就有办法找到与之对应的session对象,从而实现在两次请求之间记住该用户的信息,也就是我们之前说的用户跟踪。
在服务器端,我们想记住一个用户最简单的办法就是创建一个对象,通过这个对象就可以把用户相关的信息都保存起来,这个对象就是我们常说的session(用户会话对象)。那么问题来了,HTTP本身是一个**无连接**(每次请求和响应的过程中,服务器一旦完成对客户端请求的响应之后就断开连接)、**无状态**(客户端再次发起对服务器的请求时,服务器无法得知这个客户端之前的任何信息)的协议,即便服务器通过session对象保留了用户数据,还得通过某种方式来确定当前的请求与之前保存过的哪一个session是有关联的。相信很多人都能想到,我们可以给每个session对象分配一个全局唯一的标识符来识别session对象,我们姑且称之为sessionid,每次客户端发起请求时,只要携带上这个sessionid,就有办法找到与之对应的session对象,从而实现在两次请求之间记住该用户的信息,也就是我们之前说的用户跟踪。
要让客户端记住并在每次请求时带上sessionid又有以下几种做法:
......@@ -29,18 +175,240 @@
![](./res/cookie_xstorage_indexeddb.png)
**总结一下**,要实现用户跟踪,服务器端可以为每个用户会话创建一个session对象并将session对象的ID写入到浏览器的cookie中;用户下次请求服务器时,浏览器会在HTTP请求头中携带该网站保存的cookie信息,这样服务器就可以从cookie中找到session对象的ID并根据此ID获取到之前创建的session对象;由于session对象可以用键值对的方式保存用户数据,这样之前保存在session对象中的信息可以悉数取出,服务器也可以根据这些信息判定用户身份和了解用户偏好,为用户提供更好的个性化服务。
### Django框架对session的支持
在创建Django项目时,默认的配置文件`settings.py`文件中已经激活了一个名为`SessionMiddleware`的中间件(关于中间件的知识我们在下一个章节做详细的讲解,这里只需要知道它的存在即可),因为这个中间件的存在,我们可以直接通过请求对象的`session`属性来操作会话对象。`session`属性是一个像字典一样可以读写数据的容器对象,因此我们可以使用“键值对”的方式来保留用户数据。与此同时,`SessionMiddleware`中间件还封装了对cookie的操作,在cookie中保存了sessionid,就如同我们之前描述的那样
在创建Django项目时,默认的配置文件`settings.py`文件中已经激活了一个名为`SessionMiddleware`的中间件(关于中间件的知识我们在后面的章节做详细讲解,这里只需要知道它的存在即可),因为这个中间件的存在,我们可以直接通过请求对象的`session`属性来操作会话对象。前面我们说过,`session`属性是一个像字典一样可以读写数据的容器对象,因此我们可以使用“键值对”的方式来保留用户数据。与此同时,`SessionMiddleware`中间件还封装了对cookie的操作,在cookie中保存了sessionid,这一点我们在上面已经提到过了
在默认情况下,Django将session的数据序列化后保存在关系型数据库中,在Django 1.6以后的版本中,默认的序列化数据的方式是JSON序列化,而在此之前一直使用Pickle序列化。JSON序列化和Pickle序列化的差别在于前者将对象序列化为字符串(字符形式),而后者将对象序列化为字节串(二进制形式),因为安全方面的原因,JSON序列化成为了目前Django框架默认序列化数据的方式,这就要求在我们保存在session中的数据必须是能够JSON序列化的,否则就会引发异常。还有一点需要说明的是,使用关系型数据库保存session中的数据在大多数时候并不是最好的选择,因为数据库可能会承受巨大的压力而成为系统性能的瓶颈,在后面的章节中我们会告诉大家如何将session的数据保存到缓存服务中
在默认情况下,Django将session的数据序列化后保存在关系型数据库中,在Django 1.6以后的版本中,默认的序列化数据的方式是JSON序列化,而在此之前一直使用Pickle序列化。JSON序列化和Pickle序列化的差别在于前者将对象序列化为字符串(字符形式),而后者将对象序列化为字节串(二进制形式),因为安全方面的原因,JSON序列化成为了目前Django框架默认序列化数据的方式,这就要求在我们保存在session中的数据必须是能够JSON序列化的,否则就会引发异常。还有一点需要说明的是,使用关系型数据库保存session中的数据在大多数时候并不是最好的选择,因为数据库可能会承受巨大的压力而成为系统性能的瓶颈,在后面的章节中我们会告诉大家如何将session保存到缓存服务中以提升系统的性能
我们继续完善之前的投票应用,前一个章节中我们实现了用户的登录和注册,下面我们首先完善登录时对验证码的检查。
### 实现用户登录验证
首先,我们在刚才的`polls/utils.py`文件中编写生成随机验证码的函数`gen_random_code`,内容如下所示。
```Python
def get_captcha(request):
import random
ALL_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
def gen_random_code(length=4):
return ''.join(random.choices(ALL_CHARS, k=length))
```
编写生成验证码图片的类`Captcha`
```Python
"""
图片验证码
"""
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:
"""贝塞尔曲线"""
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:
"""验证码"""
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 ['Arial.ttf', 'Georgia.ttf', 'Action.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):
"""用于获取Captcha对象的类方法"""
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'):
"""生成验证码(文字和图片)
:param captcha_text: 验证码文字
:param fmt: 生成的验证码图片格式
:return: 验证码图片的二进制数据
"""
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):
"""生成毕达哥拉斯三角形(杨辉三角)"""
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
```
> **说明**:上面的代码中用到了三个字体文件,字体文件位于`polls/fonts`目录下,大家可以自行添加字体文件,但是需要注意字体文件的文件名跟上面代码的第45行保持一致。
接下来,我们先完成提供验证码的视图函数。
```Python
def get_captcha(request: HttpRequest) -> HttpResponse:
"""验证码"""
captcha_text = random_captcha_text()
captcha_text = gen_random_code()
request.session['captcha'] = captcha_text
image_data = Captcha.instance().generate(captcha_text)
return HttpResponse(image_data, content_type='image/png')
......@@ -49,33 +417,27 @@ def get_captcha(request):
注意上面代码中的第4行,我们将随机生成的验证码字符串保存到session中,稍后用户登录时,我们要将保存在session中的验证码字符串和用户输入的验证码字符串进行比对,如果用户输入了正确的验证码才能够执行后续的登录流程,代码如下所示。
```Python
def login(request: HttpRequest):
"""登录"""
def login(request: HttpRequest) -> HttpResponse:
hint = ''
if request.method == 'POST':
form = LoginForm(request.POST)
if form.is_valid():
# 对验证码的正确性进行验证
captcha_from_user = form.cleaned_data['captcha']
captcha_from_sess = request.session.get('captcha', '')
if captcha_from_sess.lower() != captcha_from_user.lower():
hint = '请输入正确的验证码'
else:
username = form.cleaned_data['username']
password = form.cleaned_data['password']
username = request.POST.get('username')
password = request.POST.get('password')
if username and password:
password = gen_md5_digest(password)
user = User.objects.filter(username=username, password=password).first()
if user:
# 登录成功后将用户编号和用户名保存在session中
request.session['userid'] = user.no
request.session['username'] = user.username
return redirect('/')
else:
hint = '用户名或密码错误'
else:
hint = '请输入有效的登录信息'
hint = '请输入有效的用户名和密码'
return render(request, 'login.html', {'hint': hint})
```
>**说明**:上面的代码没有对用户名和密码没有进行验证,实际项目中建议使用正则表达式验证用户输入信息,否则有可能将无效的数据交给数据库进行处理或者造成其他安全方面的隐患。
上面的代码中,我们设定了登录成功后会在session中保存用户的编号(`userid`)和用户名(`username`),页面会重定向到首页。接下来我们可以稍微对首页的代码进行调整,在页面的右上角显示出登录用户的用户名。我们将这段代码单独写成了一个名为header.html的HTML文件,首页中可以通过在`<body>`标签中添加`{% include 'header.html' %}`来包含这个页面,代码如下所示。
```HTML
......@@ -110,9 +472,9 @@ def logout(request):
其中,第1列就是浏览器cookie中保存的sessionid;第2列是经过BASE64编码后的session中的数据,如果使用Python的`base64`对其进行解码,解码的过程和结果如下所示。
```Python
>>> import base64
>>> base64.b64decode('MmI4YzViYjJhOGMyMDJkY2M5Yzg3ZWIyZGViZmUzYmYxNzdlNDdmZjp7ImNhcHRjaGEiOiJzS3d0Iiwibm8iOjEsInVzZXJuYW1lIjoiamFja2ZydWVkIn0=')
'2b8c5bb2a8c202dcc9c87eb2debfe3bf177e47ff:{"captcha":"sKwt","no":1,"username":"jackfrued"}'
import base64
base64.b64decode('MmI4YzViYjJhOGMyMDJkY2M5Yzg3ZWIyZGViZmUzYmYxNzdlNDdmZjp7ImNhcHRjaGEiOiJzS3d0Iiwibm8iOjEsInVzZXJuYW1lIjoiamFja2ZydWVkIn0=')
```
第3列是session的过期时间,session过期后浏览器保存的cookie中的sessionid就会失效,但是数据库中的这条对应的记录仍然会存在,如果想清除过期的数据,可以使用下面的命令。
......@@ -150,9 +512,34 @@ SESSION_CACHE_ALIAS = 'default'
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'
```
接下来,我们就可以限制只有登录用户才能为老师投票,修改后的`praise_or_criticize`函数如下所示,我们通过从`request.session`中获取`userid`来判定用户是否登录。
```Python
def praise_or_criticize(request: HttpRequest) -> HttpResponse:
if request.session.get('userid'):
try:
tno = int(request.GET.get('tno'))
teacher = Teacher.objects.get(no=tno)
if request.path.startswith('/praise/'):
teacher.good_count += 1
count = teacher.good_count
else:
teacher.bad_count += 1
count = teacher.bad_count
teacher.save()
data = {'code': 20000, 'mesg': '投票成功', 'count': count}
except (ValueError, Teacher.DoesNotExist):
data = {'code': 20001, 'mesg': '投票失败'}
else:
data = {'code': 20002, 'mesg': '请先登录'}
return JsonResponse(data)
```
当然,在修改了视图函数后,`teachers.html`也需要进行调整,用户如果没有登录,就将用户引导至登录页,登录成功再返回到投票页,此处不再赘述。
### 在视图函数中读写cookie
Django封装的`HttpRequest``HttpResponse`对象分别提供了读写cookie的操作。
下面我们对如何使用cookie做一个更为细致的说明以便帮助大家在Web项目中更好的使用这项技术。Django封装的`HttpRequest``HttpResponse`对象分别提供了读写cookie的操作。
HttpRequest封装的属性和方法:
......@@ -175,7 +562,7 @@ HttpResponse封装的方法:
5. `get_expire_age`/`get_expire_date`方法 - 获取会话的过期时间。
6. `clear_expired`方法 - 清理过期的会话。
下面是在执行登录之前检查浏览器是否支持cookie的代码。
下面是在执行登录之前检查浏览器是否支持cookie的代码。通常情况下,浏览器默认开启了对cookie的支持,但是可能因为某种原因,用户禁用了浏览器的cookie功能,遇到这种情况我们可以在视图函数中提供一个检查功能,如果检查到用户浏览器不支持cookie,可以给出相应的提示。
```Python
def login(request):
......@@ -191,4 +578,4 @@ def login(request):
### Cookie的替代品
之前我们说过了,cookie的名声一直都不怎么好,当然我们在实际开发中是不会在cookie中保存用户的敏感信息(如用户的密码、信用卡的账号等)的,而且保存在cookie中的数据一般也会做好编码和签名的工作。即便如此,HTML5中还是给出了用于替代cookie的技术方案,其中使用得最为广泛的就是localStorage和sessionStorage,相信从名字上你就能听出二者的差别,存储在`localStorage`的数据可以长期保留;而存储在`sessionStorage`的数据会在浏览器关闭时会被清除 。关于这些cookie替代品的用法,建议大家查阅[MDN](<https://developer.mozilla.org/zh-CN/docs/Web>)来进行了解。
\ No newline at end of file
之前我们说过了,cookie的名声一直都不怎么好,当然我们在实际开发中是不会在cookie中保存用户的敏感信息(如用户的密码、信用卡的账号等)的,而且保存在cookie中的数据一般也会做好编码和签名的工作。对于支持HTML5的浏览器来说,可以使用localStorage和sessionStorage做为cookie的替代方案,相信从名字上你就能听出二者的差别,存储在`localStorage`的数据可以长期保留;而存储在`sessionStorage`的数据会在浏览器关闭时会被清除 。关于这些cookie替代品的用法,建议大家查阅[MDN](<https://developer.mozilla.org/zh-CN/docs/Web>)来进行了解。
\ No newline at end of file
## 表单的应用
Django框架中提供了对表单的封装,而且提供了多种不同的使用方式。
首先添加用户模型。
```Python
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 = '用户'
```
通过生成迁移和执行迁移操作,在数据库中创建对应的用户表。
```Shell
(venv)$ python manage.py makemigrations vote
...
(venv)$ python manage.py migrate
...
```
定制一个非常简单的注册模板页面。
```HTML
<!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)来防范[跨站请求伪造](<https://zh.wikipedia.org/wiki/%E8%B7%A8%E7%AB%99%E8%AF%B7%E6%B1%82%E4%BC%AA%E9%80%A0>)(通常简称为CSRF),这也是Django在提交表单时的硬性要求,除非我们设置了免除CSRF令牌。下图是一个关于CSRF简单生动的例子,它来自于[维基百科](<https://zh.wikipedia.org/wiki/Wikipedia:%E9%A6%96%E9%A1%B5>)
![](./res/CSRF.png)
用户在提交注册表单时,我们还需要对用户的输入进行验证,例如我们的网站要求用户名必须由字母、数字、下划线构成且长度在4-20个字符之间,密码的长度为8-20个字符,确认密码必须跟密码保持一致。这些验证操作首先可以通过浏览器中的JavaScript代码来完成,但是即便如此,在服务器端仍然要对用户输入再次进行验证来避免将无效的数据库交给数据库,因为用户可能会禁用浏览器的JavaScript功能,也有可能绕过浏览器的输入检查将注册数据提交给服务器,所以服务器端的用户输入检查仍然是必要的。
我们可以利用Django框架封装的表单功能来对用户输入的有效性进行检查,虽然Django封装的表单还能帮助我们定制出页面上的表单元素,但这显然是一种灵活性很差的设计,这样的功能在实际开发中基本不考虑,所以表单主要的作用就在于数据验证,具体的做法如下所示。
```Python
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摘要的代码如下所示。
```Python
def to_md5_hex(message):
return hashlib.md5(message.encode()).hexdigest()
```
新增一个视图函数实现用户注册的功能。
```Python
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配置。
```Python
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。
我们再来定制一个非常简单的登录页。
```HTML
<!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(光学文字识别)破解验证码的程序。下面的代码封装了生成验证码图片的功能,大家可以直接用这些代码来生成图片验证码,不要“重复发明轮子”。
```Python
"""
图片验证码
"""
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对象输出到用户浏览器中。
```Python
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')
```
生成的验证码如下图所示。
![](./res/captcha.png)
为了验证用户提交的登录表单,我们再定义个表单类。
```Python
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没有跟模型绑定,定义了三个字段分别对应登录表单中的用户名、密码和验证码。接下来是处理用户登录的视图函数。
```Python
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。
```Python
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`属性为自定义的表单即可,例如:
```Python
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)
```
\ No newline at end of file
......@@ -14,7 +14,7 @@
那么结论就很简单了,只要我们找到了这些网络API接口,那么就能通过这些接口获取到数据,当然实际开发的时候可能还要对这些接口的参数以及接口返回的数据进行分析,了解每个参数的意义以及返回的JSON数据的格式,这样才能在我们的爬虫中使用这些数据。
关于如何从网络API中获取JSON格式的数据并提取出我们需要的内容,在之前的[《文件和异常》](../Day01-15/Day11/文件和异常.md)一文中已经讲解过了,这里不再进行赘述。
### 使用Selenium
......
## 表单交互和验证码处理
### 提交表单
#### 手动提交
#### 自动提交
### 验证码处理
#### 加载验证码
#### 光学字符识别
光学字符识别(OCR)是从图像中抽取文本的工具,可以应用于公安、电信、物流、金融等诸多行业,例如识别车牌,身份证扫描识别、名片信息提取等。在爬虫开发中,如果遭遇了有文字验证码的表单,就可以利用OCR来进行验证码处理。Tesseract-OCR引擎最初是由惠普公司开发的光学字符识别系统,目前发布在Github上,由Google赞助开发。
![](./res/tesseract.gif)
#### 处理更复杂的验证码
很多网站为了分别出提供验证码的是人还是机器使用了更为复杂的验证码,例如拼图验证码、点触验证码、九宫格验证码等。关于这方面的知识,在崔庆才同学的[《Python 3网络爬虫开发实战》](http://www.ituring.com.cn/book/2003)有较为详细的讲解,有兴趣的可以购买阅读。
#### 验证码处理服务
......@@ -221,34 +221,28 @@
### Day41~55 - [实战Django](./Day41-55)
#### Day41 - [快速上手](./Day41-55/41.快速上手.md)
#### Day41 - [Django快速上手](./Day41-55/41.Django快速上手.md)
- Web应用工作原理和HTTP协议
- Web应用工作机制
- HTTP请求和响应
- Django框架概述
- 5分钟快速上手
- 使用视图模板
#### Day42 - [深入模型](./Day41-55/42.深入模型.md)
- 关系型数据库配置
- 管理后台的使用
- 使用ORM完成对模型的CRUD操作
- 管理后台的使用
- Django模型最佳实践
- 模型定义参考
#### Day43 - [静态资源和Ajax请求](./Day41-55/43.静态资源和Ajax请求.md)
- 加载静态资源
- 用Ajax请求获取数据
#### Day44 - [表单的应用](./Day41-55/44.表单的应用.md)
- 表单和表单控件
- 跨站请求伪造和CSRF令牌
- Form和ModelForm
- 表单验证
- Ajax概述
- 用Ajax实现投票功能
#### Day45 - [Cookie和Session](./Day41-55/45.Cookie和Session.md)
#### Day44 - [Cookie和Session](./Day41-55/44.Cookie和Session.md)
- 实现用户跟踪
- cookie和session的关系
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册