在上一章节中,我们完成了静态资源配置和 Ajax 无刷新投票功能,本节课将聚焦用户登录认证核心场景,通过 Cookie 和 Session 实现用户跟踪,限制仅登录用户可投票,完整实现 “登录 - 投票 - 注销” 的权限控制链路。
不同于之前的 “反向工程(数据表→模型)”,本次通过正向工程(模型→数据表) 创建用户模型,定义用户核心字段:
```Pythonclass 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 = '用户'```执行以下命令,将 User 模型转换为数据库中的tb_user表:
```Bashpython manage.py makemigrations pollspython manage.py migrate polls```为避免明文存储密码,先将密码转换为MD5摘要(128 位哈希值,32 位 16 进制字符串),再插入测试数据:
```SQLinsert into `tb_user` (`username`, `password`, `tel`, `reg_date`)values ('wangdachui', '1c63129ae9db9c60c3e8aa94d3e00495', '13122334455', now()), ('hellokitty', 'c6f8cf68e5f68b0aa4680e089ee4742c', '13890006789', now());```> **说明**:上面创建的两个用户`wangdachui`和`hellokitty`密码分别是`1qaz2wsx`和`Abc123!!`。在polls/utils.py中封装 MD5 加密函数,用于密码加密和验证:
```Pythonimport hashlibdef gen_md5_digest(content): return hashlib.md5(content.encode()).hexdigest()```(1)登录视图函数(基础版)
```Pythondef login(request: HttpRequest) -> HttpResponse: hint = '' return render(request, 'login.html', {'hint': hint})```(2)登录模板页(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-transform: translateY( 20px; } form+div>a { text-decoration: none; color: darkcyan; font-size: 1.2em); } .button { width: 500px; text-align: center; margin-transform: translateY( 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简单生动的例子。接下来,我们可以编写提供验证码和实现用户登录的视图函数,在此之前,我们先说说一个Web应用实现用户跟踪的方式以及Django框架对实现用户跟踪所提供的支持。对一个Web应用来说,用户登录成功后必然要让服务器能够记住该用户已经登录,这样服务器才能为这个用户提供更好的服务,而且上面说到的CSRF也是通过钓鱼网站来套取用户登录信息进行恶意操作的攻击手段,这些都是以用户跟踪技术为基础的。在理解了这些背景知识后,我们就清楚用户登录时到底需要执行哪些操作。
HTTP 是无连接、无状态协议:
为实现 “记住用户”(用户跟踪),需通过Session(服务端)+ Cookie(客户端) 协作:
Session对象(存储用户信息),生成唯一sessionid;sessionid写入浏览器 Cookie;sessionid,服务器据此找到对应 Session;http://www.example.com/index.html?sessionid=123456,服务器通过获取sessionid参数的值来取到与之对应的session对象。<input type="hidden" name="sessionid" value="123456">。

总结一下,要实现用户跟踪,服务器端可以为每个用户会话创建一个session对象并将session对象的ID写入到浏览器的cookie中;用户下次请求服务器时,浏览器会在HTTP请求头中携带该网站保存的cookie信息,这样服务器就可以从cookie中找到session对象的ID并根据此ID获取到之前创建的session对象;由于session对象可以用键值对的方式保存用户数据,这样之前保存在session对象中的信息可以悉数取出,服务器也可以根据这些信息判定用户身份和了解用户偏好,为用户提供更好的个性化服务。
Django 默认激活SessionMiddleware中间件,提供以下核心能力:
request.session:类似字典的对象,可直接读写用户数据;sessionid;django_session表中。(1)验证码工具函数(polls/utils.py)
import randomALL_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'defgen_random_code(length=4):return''.join(random.choices(ALL_CHARS, k=length))(2)验证码图片生成类(polls/utils.py)
"""图片验证码"""import osimport randomfrom io import BytesIOfrom PIL import Imagefrom PIL import ImageFilterfrom PIL.ImageDraw import Drawfrom PIL.ImageFont import truetypeclassBezier:"""贝塞尔曲线"""def__init__(self):self.tsequence = tuple([t / 20.0for t inrange(21)])self.beziers = {}defmake_bezier(self, n):"""绘制贝塞尔曲线"""try:returnself.beziers[n]except KeyError: combinations = pascal_row(n - 1) result = []for t inself.tsequence: tpowers = (t ** i for i inrange(n)) upowers = ((1 - t) ** i for i inrange(n - 1, -1, -1)) coefs = [c * a * b for c, a, b inzip(combinations, tpowers, upowers)] result.append(coefs)self.beziers[n] = resultreturn resultclassCaptcha:"""验证码"""def__init__(self, width, height, fonts=None, color=None):self._image = Noneself._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 @classmethoddefinstance(cls, width=200, height=75):"""用于获取Captcha对象的类方法""" prop_name = f'_instance_{width}_{height}'ifnothasattr(cls, prop_name):setattr(cls, prop_name, cls(width, height))returngetattr(cls, prop_name)def_background(self):"""绘制背景""" Draw(self._image).rectangle([(0, 0), self._image.size], fill=random_color(230, 255))def_smooth(self):"""平滑图像"""returnself._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 inrange(1, number)] bcoefs = Bezier().make_bezier(number - 1) points = []for coefs in bcoefs: points.append(tuple(sum([coef * p for coef, p inzip(coefs, ps)])for ps inzip(*path))) Draw(self._image).line(points, fill=color if color elseself._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 inrange(number): x = int(random.uniform(dx, width)) y = int(random.uniform(dy, height)) draw.line(((x, y), (x + level, y)), fill=color if color elseself._color, width=level)def_text(self, captcha_text, fonts, font_sizes=None, drawings=None, squeeze_factor=0.75, color=None):"""绘制文本""" color = color if color elseself._color fonts = tuple([truetype(name, size)for name in fontsfor 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) @staticmethoddef_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.sizereturn warp_image.transform( (width, height), Image.QUAD, (x1, y1, -x1, height2 - y2, width2 + x2, height2 + y2, width2 - x2, -y1)) @staticmethoddef_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 @staticmethoddef_rotate(image, angle=25):"""图像旋转"""return image.rotate(random.uniform(-angle, angle), Image.BILINEAR, expand=1)defgenerate(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()defpascal_row(n=0):"""生成毕达哥拉斯三角形(杨辉三角)""" result = [1] x, numerator = 1, nfor denominator inrange(1, n // 2 + 1): x *= numerator x /= denominator result.append(x) numerator -= 1if n & 1 == 0: result.extend(reversed(result[:-1]))else: result.extend(reversed(result))return resultdefrandom_color(start=0, end=255, opacity=255):"""获得随机颜色""" red = random.randint(start, end) green = random.randint(start, end) blue = random.randint(start, end)if opacity isNone:return red, green, bluereturn red, green, blue, opacity说明:上面的代码中用到了三个字体文件,字体文件位于
polls/fonts目录下,大家可以自行添加字体文件,但是需要注意字体文件的文件名跟上面代码的第45行保持一致。
(3)验证码视图函数
defget_captcha(request: HttpRequest) -> HttpResponse:"""验证码""" 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')deflogin(request: HttpRequest) -> HttpResponse: hint = ''if request.method == 'POST': 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: request.session['userid'] = user.no request.session['username'] = user.usernamereturn redirect('/')else: hint = '用户名或密码错误'else: hint = '请输入有效的用户名和密码'return render(request, 'login.html', {'hint': hint})说明:上面的代码没有对用户名和密码没有进行验证,实际项目中建议使用正则表达式验证用户输入信息,否则有可能将无效的数据交给数据库进行处理或者造成其他安全方面的隐患。
创建header.html模板片段,根据 Session 中的用户信息展示不同内容:
<divclass="user"> {% if request.session.userid %}<span>{{ request.session.username }}</span><ahref="/logout">注销</a> {% else %}<ahref="/login">登录</a> {% endif %}<ahref="/register">注册</a></div>deflogout(request):"""注销""" request.session.flush()return redirect('/')改造投票视图函数,增加登录验证:
defpraise_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_countelse: 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做一个更为细致的说明以便帮助大家在Web项目中更好地使用这项技术。Django封装的HttpRequest和HttpResponse对象分别提供了读写cookie的操作。
HttpRequest封装的属性和方法:
COOKIES属性 - 该属性包含了HTTP请求携带的所有cookie。get_signed_cookie方法 - 获取带签名的cookie,如果签名验证失败,会产生BadSignature异常。HttpResponse封装的方法:
set_cookie方法 - 该方法可以设置一组键值对并将其最终将写入浏览器。set_signed_cookie方法 - 跟上面的方法作用相似,但是会对cookie进行签名来达到防篡改的作用。因为如果篡改了cookie中的数据,在不知道密钥和盐的情况下是无法生成有效的签名,这样服务器在读取cookie时会发现数据与签名不一致从而产生BadSignature异常。需要说明的是,这里所说的密钥就是我们在Django项目配置文件中指定的SECRET_KEY,而盐是程序中设定的一个字符串,你愿意设定为什么都可以,只要是一个有效的字符串。上面提到的方法,如果不清楚它们的具体用法,可以自己查阅一下Django的官方文档,没有什么资料比官方文档能够更清楚的告诉你这些方法到底如何使用。
刚才我们说过了,激活SessionMiddleware之后,每个HttpRequest对象都会绑定一个session属性,它是一个类似字典的对象,除了保存用户数据之外还提供了检测浏览器是否支持cookie的方法,包括:
set_test_cookie方法 - 设置用于测试的cookie。test_cookie_worked方法 - 检测测试cookie是否工作。delete_test_cookie方法 - 删除用于测试的cookie。set_expiry方法 - 设置会话的过期时间。get_expire_age/get_expire_date方法 - 获取会话的过期时间。clear_expired方法 - 清理过期的会话。下面是在执行登录之前检查浏览器是否支持cookie的代码。通常情况下,浏览器默认开启了对cookie的支持,但是可能因为某种原因,用户禁用了浏览器的cookie功能,遇到这种情况我们可以在视图函数中提供一个检查功能,如果检查到用户浏览器不支持cookie,可以给出相应的提示。
deflogin(request):if request.method == 'POST':if request.session.test_cookie_worked(): request.session.delete_test_cookie()# Add your code to perform login process hereelse:return HttpResponse("Please enable cookies and try again.") request.session.set_test_cookie()return render_to_response('login.html')对于现代浏览器,可使用 HTML5 存储 API 替代 Cookie:
localStorage:永久存储,除非手动删除;sessionStorage:会话级存储,关闭浏览器即清空;IndexedDB:大容量结构化存储。本次实战完成了:
国内直接使用顶级AI工具
谷歌浏览器访问:
https://www.nezhasoft.cloud/r/vMPJZr
