请求验证码
- 思路设计
- 页面加载完成时,前端生成随机码 image_code_id 向后端发起GET请求,获取验证码图片
- 后端写验证码图片接口,最终返回图片的二进制流返回给前端渲染图片
- 把 image_code_id 和图形脚本生成的 text 存入 redis 并设置过期时间(一般一分钟)
-
请求方式
请求方法 请求地址 GET /image_codes/image_code_id/ -
请求参数
参数名 类型 是否必传 说明 image_code_id string 是 随机码 -
响应结果:图片(二进制流)
图片验证码
- 依赖Pillow库,先安装
pip install Pillow
- scripts(项目根目录),新建 captcha包
里面再新建fonts目录,用来存放字体文件
- actionj.ttf
- Arial.ttf
- Georgia.ttf
### captcha.captcha
# -*- coding: utf-8 -*-
# refer to `https://bitbucket.org/akorn/wheezy.captcha`
import random
import string
import os.path
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 pascal_row(self, n):
""" Returns n-th row of Pascal's triangle
"""
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 make_bezier(self, n):
""" Bezier curves:
http://en.wikipedia.org/wiki/B%C3%A9zier_curve#Generalization
"""
try:
return self.beziers[n]
except KeyError:
combinations = self.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):
self._bezier = Bezier()
self._dir = os.path.dirname(__file__)
# self._captcha_path = os.path.join(self._dir, '..', 'static', 'captcha')
@staticmethod
def instance():
if not hasattr(Captcha, "_instance"):
Captcha._instance = Captcha()
return Captcha._instance
def initialize(self, width=200, height=75, color=None, text=None, fonts=None):
# self.image = Image.new('RGB', (width, height), (255, 255, 255))
self._text = text if text else random.sample(string.ascii_uppercase + string.ascii_uppercase + '3456789', 4)
self.fonts = fonts if fonts else \
[os.path.join(self._dir, 'fonts', font) for font in ['Arial.ttf', 'Georgia.ttf', 'actionj.ttf']]
self.width = width
self.height = height
self._color = color if color else self.random_color(0, 200, random.randint(220, 255))
@staticmethod
def random_color(start, end, opacity=None):
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
# draw image
def background(self, image):
Draw(image).rectangle([(0, 0), image.size], fill=self.random_color(238, 255))
return image
@staticmethod
def smooth(image):
return image.filter(ImageFilter.SMOOTH)
def curve(self, image, width=4, number=6, color=None):
dx, height = image.size
dx /= number
path = [(dx * i, random.randint(0, height))
for i in range(1, number)]
bcoefs = self._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(image).line(points, fill=color if color else self._color, width=width)
return image
def noise(self, image, number=50, level=2, color=None):
width, height = image.size
dx = width / 10
width -= dx
dy = height / 10
height -= dy
draw = Draw(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)
return image
def text(self, image, 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(image)
char_images = []
for c in self._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 = 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)
image.paste(char_image,
(offset, int((height - c_height) / 2)),
mask)
offset += int(c_width * squeeze_factor)
return image
# draw text
@staticmethod
def warp(image, dx_factor=0.27, dy_factor=0.21):
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))
image2 = Image.new('RGB',
(width + abs(x1) + abs(x2),
height + abs(y1) + abs(y2)))
image2.paste(image, (abs(x1), abs(y1)))
width2, height2 = image2.size
return image2.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)
image2 = Image.new('RGB', (width + dx, height + dy))
image2.paste(image, (dx, dy))
return image2
@staticmethod
def rotate(image, angle=25):
return image.rotate(
random.uniform(-angle, angle), Image.BILINEAR, expand=1)
def captcha(self, path=None, fmt='JPEG'):
"""Create a captcha.
Args:
path: save path, default None.
fmt: image format, PNG / JPEG.
Returns:
A tuple, (name, text, StringIO.value).
For example:
('fXZJN4AFxHGoU5mIlcsdOypa', 'JGW9', '\x89PNG\r\n\x1a\n\x00\x00\x00\r...')
"""
image = Image.new('RGB', (self.width, self.height), (255, 255, 255))
image = self.background(image)
image = self.text(image, self.fonts, drawings=['warp', 'rotate', 'offset'])
image = self.curve(image)
image = self.noise(image)
image = self.smooth(image)
name = "".join(random.sample(string.ascii_lowercase + string.ascii_uppercase + '3456789', 24))
text = "".join(self._text)
out = BytesIO()
image.save(out, format=fmt)
if path:
image.save(os.path.join(path, name), fmt)
return name, text, out.getvalue()
def generate_captcha(self):
self.initialize()
return self.captcha("")
captcha = Captcha.instance()
if __name__ == '__main__':
# 返回 name,text, image
('9md3IwpPBLyXO7YuJFEMjnzK', '9YMI', b'\xff\xd8\xff\......')
print(captcha.generate_captcha())
### captcha.captcha_demo
### 测试脚本demo.py
# from .captcha import captcha
# __main__ is not a package报错:引用相对路径造成的,修改成以下这句即可
from captcha import captcha
# name是文件名称ID
# text 文本验证码
# image 验证码图片二进制流
name,text, image = captcha.generate_captcha()
print('名称是',name) # TdLDlWyaXZMPptHi6jNu4Jqh
print('验证码的内容是:', text) # UXZR
# 将验证码图片二进制流 存入图片(点击刚生成的图片,看效果)
with open('demo.png', 'wb') as f:
f.write(image)
- 图片demo测试完毕以后,就要写后端接口,生成图片,返回给前端显示
- 验证码前/后端的逻辑
- 前端生成随机字符串码(image_code_id),发送给后端
- 后端接收image_code_id,根据 验证码脚本 生成 图片文本text 和 图片二进制码image
- 后端把 image_code_id 和 text 存入redis 并把 image 返回给前端展示
### register.html
......
<li>
<label>图形验证码:</label>
<input type="text" v-model="image_code" @blur="check_image_code" name="pic_code" id="pic_code" class="msg_input">
<!--动态src属性,用户单击图片的时候,就刷新src属性-->
<img :src="image_code_url" @click="generate_image_code" alt="图形验证码" class="pic_code">
<span v-show="error_image_code" class="error_tip">{{ error_image_code_message }}</span>
</li>
......
<scripts>
// 生成uuid
generate_uuid: function () {
var d = new Date().getTime();
if (window.performance && typeof window.performance.now === "function") {
d += performance.now(); //use high-precision timer if available
}
var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
return uuid;
},
// 生成一个图片验证码的编号,并设置页面中图片验证码img标签的src属性
generate_image_code: function(){
// 生成一个编号
// 严格一点的使用uuid保证编号唯一, 不是很严谨的情况下,也可以使用时间戳
this.image_code_id = this.generate_uuid();
// 设置页面中图片验证码img标签的src属性
this.image_code_url = this.host + "/image_codes/" + this.image_code_id + "/";
}
......
</scripts>
### apps.users.urls
from django.conf.urls import url
from . import views
urlpatterns = [
......
# 验证码(接收image_code_id)
url('^image_codes/(?P<image_code_id>[\w-]+)/$', views.ImageCodeView.as_view()),
]
### views
from rest_framework.views import APIView
from rest_framework.response import Response
from django.http import HttpResponse
from scripts.captcha.captcha import captcha
from django_redis import get_redis_connection
......
class ImageCodeView(APIView):
def get(self,request,image_code_id):
_,text,image = captcha.generate_captcha()
redis_conn = get_redis_connection('verify_codes')
redis_conn.set(image_code_id,text,ex=60)
return HttpResponse(image,content_type='images/jpg')
- 至此,刷新注册页面,查看验证码效果
注册的时候,校验手机号码是否已存在
- 思路设计(用户名是同样的套路,不再赘述)
- 前端带着电话号码发GET请求
- 后端把接收的电话号码去db查一次,返回 count,均响应200
- 0 表示用户从未注册过
- 值大于0 表示用户已注册过
-
请求方式
请求方法 请求地址 get http://127.0.0.1:8000/mobile_count/mobile/exists/ -
请求参数
参数名 类型 是否必传 说明 mobile string 是 手机号码 -
响应成功结果:JSON
字段 值 说明 mobile xxxxxxxxxxxxx 用户手机号码 count 0/其他值 只有0表示用户未注册过(合理请求) -
后端接口
- 拿前端传过来的mobile去db查一次,正常响应即可 - 等到用户点击注册按钮的时候,再报错
### urls
from django.urls import path
from django.conf.urls import url,include
from .import views
urlpatterns = [
......
# 若用户输入的号码不符合正则path,请求就无法到达接口
url('^mobile_count/(?P<mobile>1[3-9]\d{9})/exists/$',views.MobileExistsView.as_view())
]
### views
class MobileExistsView(APIView):
def get(self, request, mobile):
count = UserInfo.objects.filter(mobile=mobile).count()
data = {
'mobile':mobile,
'count':count
}
return Response(data)
用户输入'手机号码'和'图形验证码',点击'获取验证码'按钮,发送短信
- 设计思路
- 前端带着三个参数 mobile,image_code_id 和 text 向后端发起GET请求
- 后端校验 验证码 是否正确,若正确就发送短信到用户手机,并响应json
- 后端接口写法
- 序列化器(校验验证码是否正确)
- 丢到 validate 统一处理
- 根据 image_code_id 去redis取text
- 用 text 和 用户输入的text进行对比
- 不等就抛异常
- 视图
- 序列化器校验是否通过,不通过就抛异常
- 去redis检查mobile的flag标识是否存在,存在就返回400信息
- 生成随机短信码,调用短信接口发送验证码[celery异步];把mobile && 短信码 存入redis并设置有效期;
- 设立短信标识也存入redis并设置有效期
- 响应json
-
请求方式
请求方法 请求地址 GET /sms_code/mobile/?image_code_id=xxx&text=xxxx -
请求参数
参数名 类型 是否必传 说明 mobile string 是 手机号码 image_code_id string 是 随机码 text string 是 验证码 -
响应成功结果:JSON
字段 值 说明 msg 发送短信成功 无 -
后端接口注意事项
- 用户输入正确的图形验证码,短信才能发送成功 - 所以视图采用 GenericAPIView,好处在于可以自定义 serializer_class 来对字段进行校验 如果继承 APIView,所有的逻辑都要在get()方法实现,代码显得冗长
### user.serializers
from rest_framework import serializers
from django_redis import get_redis_connection
class ImageCodeCheckSerializer(serializers.Serializer):
image_code_id = serializers.CharField(label='图形验证码ID')
text = serializers.CharField(label='图形验证码文本')
def validate(self,attrs):
image_code_id = attrs.get('image_code_id')
text = attrs.get('text')
# 如何获取mobile?(views一定要继承GenericAPIView,如果继承APIView,是没有办法获取到mobile的(源码分析))
# mobile = self.context['view'].kwargs['mobile']
redis_conn = get_redis_connection('verify_codes')
redis_byte_text = redis_conn.get(image_code_id)
if not redis_byte_text:
raise serializers.ValidationError('验证码不存在或已过期')
if text.lower() != redis_byte_text.decode('utf-8').lower():
raise serializers.ValidationError('验证码错误')
return attrs
### users.views
# class SMSCodeView(APIView):
class SMSCodeView(GenericAPIView):
serializer_class = ImageCodeCheckSerializer
def get(self, request, mobile):
serializer = self.get_serializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
redis_conn = get_redis_connection('verify_codes')
sms_time_flag = redis_conn.get('sms_time_flag_{}'.format(mobile))
if sms_time_flag:
return Response({'msg':'短信发送太频繁'},status=status.HTTP_400_BAD_REQUEST)
sms_code = random.randint(1000,9999)
print(sms_code,'调用短信接口发送短信成功')
redis_conn = get_redis_connection('verify_codes')
pl = redis_conn.pipeline()
pl.set(mobile,sms_code,ex=60)
pl.set('sms_time_flag_{}'.format(mobile),100,ex=60)
pl.execute()
return Response({'msg':'发送短信成功'})
-
前端错误优化
- 按照目前的逻辑,只要后端返回400错误,前端只会提示'验证码错误'
而实际上还有一个'短信发送太频繁'的错误,对用户不够友好
- 按照目前的逻辑,只要后端返回400错误,前端只会提示'验证码错误'
......
.catch(error => {
if (error.response.status == 400) {
// 只提示这个错误
this.error_image_code_message = '图片验证码有误';
this.error_image_code = true;
this.generate_image_code();
} else {
console.log(error.response.data);
}
this.send_flag = false;
});
- 解决办法
### 后端
class SMSCodeView(GenericAPIView):
serializer_class = ImageCodeCheckSerializer
def get(self, request, mobile):
serializer = self.get_serializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
redis_conn = get_redis_connection('verify_codes')
sms_time_flag = redis_conn.get('sms_time_flag_{}'.format(mobile))
if sms_time_flag:
# 增加标识码
return Response({'msg':'短信发送太频繁','status_code':1004},status=status.HTTP_400_BAD_REQUEST)
# return Response({'msg':'短信发送太频繁'},status=status.HTTP_400_BAD_REQUEST)
# 不可以正常响应,如果正常响应,则返回200状态码,前端无法捕获这个错误
# return Response({'msg':'短信发送太频繁','status_code':1004})
sms_code = random.randint(1000,9999)
......
return Response({'msg':'发送短信成功'})
### 前端
......
.catch(error => {
if (error.response.status == 400) {
// 增加标识码判断
if(error.response.data.status_code == 1004){
this.error_image_code_message = error.response.data.msg;
} else {
this.error_image_code_message = '图片验证码有误';
}
this.error_image_code = true;
this.generate_image_code();
// this.error_image_code_message = '图片验证码有误';
// this.error_image_code = true;
// this.generate_image_code();
} else {
console.log(error.response.data);
}
this.send_flag = false;
});
celery: 处理'耗时'和'定时'任务
- 引入场景: 在发短信的逻辑中,若是遇到网络问题,造成发短信的逻辑阻塞了
程序堵在那边,此时,用户的web界面中,发短信的按钮不会立即显示倒计时
会等到程序恢复运行以后,再显示倒计时效果- 缺陷很明显,用户体验很差
def get(self,request,mobile):
......
pl.execute()
time.sleep(5) # 模拟耗时操作(此时前端的'获取短信按钮'要等5秒以后才会出现倒计时)
print('已发送短信')
return Response({'msg':'OK'})
-
解决办法,把'耗时'操作,变成'异步请求操作'
即执行到'耗时操作'以后,不等结果,立即返回Response
这样就不会让用户等了...(实质就是一个'假象'...),从而优化用户体验 -
celery 可以分为三部分
-
客户端: 把任务发送给'任务队列(broker)',代码逻辑就放在这里
-
任务队列(broker,经纪人,中间人): 存放一个个任务队列
-
任务处理者(worker):从broker获取任务并处理它
-
-
客户端配置
- 项目根目录新建'celery_tasks包'(一般都是叫这个名字,没有硬性规定)
- 新建 main.py: celery 主程序入口
- 新建 config.py: 存放 celery 配置
- 在 celery_tasks包目录下,新建'sms包'(任务包)
- sms包底下,新建'tasks.py'(一定要叫这个名字,硬性规定)
- main.py的写法
from celery import Celery
# 创建实例对象
celery_app = Celery('meiduo')
# 加载配置文件
celery_app.config_from_object('celery_tasks.config')
# 自动注册异步任务
celery_app.autodiscover_tasks(['celery_tasks.sms'])
# celery_app.autodiscover_tasks(['celery_tasks.sms','celery_tasks.html']) # 如果有多个任务,可以这么写
- config.py的写法
# 指定任务队列存储的位置(这边是指定7号数据库)
broker_url = "redis://192.168.11.38:6379/7"
- 现在,运行命令(终端切换到celery_tasks父目录),把celery跑起来
celery -A celery_tasks.main worker -l info
- 如果是windows系统,此时可能会报错
......
import grp ModuleNotFoundError: No module named 'grp'
- 原因及解决办法
- 原因
- 使用的是 PyPi 软件包django-celery-beat,会安装所需软件包celery 的最新版本
- 而不是安装兼容版本(到我发布此内容时,即 2021 年 5 月 25 日,这时是v5.1.0)
- 它似乎与django-celery-beat版本2.2.0(最新)以及 Windows 操作系统存在兼容性问题
- 解决办法
pip uninstall celery
pip install celery==5.0.5
- 再次运行命令,可以看到 celery 跑起来了: celery -A celery_tasks.main worker -l info
- 在tasks.py中,自定义任务(所谓'任务',就是一个'函数')
# sms.tasks
from celery_tasks.main import celery_app
# 使用装饰器注册任务,不要写成"@celery_app.tasks",多了一个's'就会报错...
@celery_app.task(name='send_sms_code') # 给任务函数取一个名字(celery默认的任务名很长...)
def send_sms_code(mobile,sms_code):
# 把短信api搞到sms包即可
print('发送短信')
- 修改项目中,发送短信的接口,变成celery任务
from celery_tasks.sms.tasks import send_sms_code
......
# print('已发送短信')
# 导入任务函数,使用delay传参并调用celery任务
send_sms_code.delay(mobile,sms_code)
......
- 重新启动celery,可以看到如下信息(注意事项: 若修改tasks任务,celery必须手动重启才能识别新的逻辑)
-------------- celery@DESKTOP-SM4B347 v5.0.5 (singularity)
--- ***** -----
-- ******* ---- Windows-10-10.0.22000-SP0 2022-11-25 13:44:23
- *** --- * ---
- ** ---------- [config]
- ** ---------- .> app: meiduo:0x163186ef550
- ** ---------- .> transport: redis://192.168.11.38:6379/7
- ** ---------- .> results: disabled://
- *** --- * --- .> concurrency: 4 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** -----
-------------- [queues]
.> celery exchange=celery(direct) key=celery
[tasks]
. send_sms_code # 任务名称(刚才取名)
[2022-11-25 13:44:23,881: INFO/MainProcess] Connected to redis://192.168.11.38:6379/7
[2022-11-25 13:44:23,896: INFO/MainProcess] mingle: searching for neighbors
[2022-11-25 13:44:24,193: INFO/SpawnPoolWorker-3] child process 6872 calling self.run()
[2022-11-25 13:44:24,203: INFO/SpawnPoolWorker-1] child process 13232 calling self.run()
[2022-11-25 13:44:24,234: INFO/SpawnPoolWorker-2] child process 4552 calling self.run()
[2022-11-25 13:44:24,250: INFO/SpawnPoolWorker-4] child process 12772 calling self.run()
[2022-11-25 13:44:24,914: INFO/MainProcess] mingle: all alone
[2022-11-25 13:44:24,945: INFO/MainProcess] celery@DESKTOP-SM4B347 ready.
- 当用户点击获取验证码的时候,celery提示信息如下,显示已经完成发送短信的逻辑
......
[2022-11-25 13:46:03,093: INFO/MainProcess] Received task: send_sms_code[99ee18a0-d3a7-4d46-af15-30b5c63ec7f0]
[2022-11-25 13:46:03,095: WARNING/SpawnPoolWorker-3] 发送短信
[2022-11-25 13:46:03,096: INFO/SpawnPoolWorker-3] Task send_sms_code[99ee18a0-d3a7-4d46-af15-30b5c63ec7f0] succeeded in 0.0s: None
......
注册功能
-
请求方式
请求方法 请求地址 post http://127.0.0.1:8000/register/ -
请求参数
参数名 类型 是否必传 说明 username string 是 用户名 password string 是 密码 password string 是 确认密码 mobile string 是 手机号码 sms_code string 是 短信验证码 -
响应成功结果:JSON
字段 值 说明 id 数字 用户ID mobile 数字 用户手机号码 token xxx.xxx.xxx jwt校验字符串 -
后端接口注意事项
- 注册是一个标准的新增行为,所以继承 CreateAPIView - 指定序列化器即可,所有的逻辑都在序列化器中完成 - 字段的校验 - 重写create() - db字段的保存 - 最重要点: jwt的签发逻辑扔在这里执行,给user对象绑定token返回给前端存储
jwt
认证
- 安装
pip install djangorestframework-jwt
utils
新建jwt_auth
脚本,插件默认只返回token
,我们再加一点东东,比如user模型
的相关信息,例如username
和user_id
### utils.jwt_auth.py
def jwt_response_payload_handler(token, user=None, request=None):
"""
自定义jwt认证成功返回数据
"""
print('自定义jwt认证成功返回数据....')
return {
'token': token,
'user_id': user.id,
'username': user.username
}
settings
配置
### settings
......
# ------------JWT校验------------------#
JWT_AUTH = {
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1), # token令牌有效期
'JWT_RESPONSE_PAYLOAD_HANDLER': 'utils.jwt_auth.jwt_response_payload_handler', # 重写导包路径,加入自定义的逻辑
}
-
后端
-
视图很简洁(最终返回user对象给前端渲染,比如渲染username)
### users.views class RegisterView(CreateAPIView): serializer_class = RegisterSerializer # 自定义序列化类 ### users.urls urlpatterns = [ # 注册 url(r'^register/$',views.RegisterView.as_view()), # 图形验证码 ...... # 发送短信 ...... ]
-
序列化器逻辑(当所有字段都通过校验以后,在views实现create逻辑也是可以的,只不过为了保持views简洁,就放在序列化类搞)
### user.serializers class RegisterSerializer(serializers.ModelSerializer): ''' - 前端提交的字段: - 用户名,密码,确认密码,手机号,短信验证码,同意协议 - 实际场景中,图形验证码是不需要的,所以这里忽略 ''' password2 = serializers.CharField(write_only=True, label='重复密码') sms_code = serializers.CharField(write_only=True, label='短信验证码') allow = serializers.CharField(write_only=True, label='是否同意协议') token = serializers.CharField(read_only=True, label='token') class Meta: model = UserInfo fields = ['id', 'username', 'password', 'password2', 'mobile', 'sms_code', 'allow', 'token'] extra_kwargs = { # 修改字段选项(作基本的校验) 'username': { 'min_length': 5, 'max_length': 20, 'error_messages': { 'min_length': '仅允许5-20个字符的用户名', 'max_length': '仅允许5-20个字符的用户名', } }, 'password': { 'write_only': True, 'min_length': 8, 'max_length': 20, 'error_messages': { 'min_length': '仅允许8-20个字符的用户名', 'max_length': '仅允许8-20个字符的用户名', } }, } def validate_username(self, value): exists = UserInfo.objects.filter(username=value).exists() if exists: raise serializers.ValidationError('用户名已存在') return value def validate_mobile(self, value): pattern = r'1[3-9]\d{9}' if not re.match(pattern, value): raise serializers.ValidationError('手机号码格式有误') exists = UserInfo.objects.filter(mobile=value).exists() if exists: raise serializers.ValidationError('手机号码已存在') return value def validate_allow(self, value): if value != 'true': raise serializers.ValidationError('请同意协议') return value def validate(self, attrs): if attrs['password'] != attrs['password2']: raise serializers.ValidationError('密码不一致') mobile = attrs.get('mobile') conn = get_redis_connection('verify_codes') redis_code = conn.get(mobile) if redis_code is None: raise serializers.ValidationError('验证码已过期,请重新获取') user_code = attrs['sms_code'] # 一定要先检验code是否为None,否则调用decode()可能报错,None当然没有decode方法... format_redis_code = redis_code.decode() if user_code.lower() != format_redis_code.lower(): raise serializers.ValidationError('验证码错误') return attrs # 必须重写,比如密码需要加密,还需要生成jwt-token def create(self, validated_data): ''' - is_valid()有自动剔除的功能,下次试试(使用CreateAPIView,没有机会调用is_valid()) ''' del validated_data['password2'] del validated_data['sms_code'] del validated_data['allow'] # 密码要加密一下,最后保存用户对象 password = validated_data.pop('password') user = UserInfo(**validated_data) user.set_password(password) user.save() # 手动签发令牌(调用拓展库提供的两个函数,生成payload并签发token) # 注册只涉及到生成token,并没有涉及到'token校验'(登录才需要校验) # 把模型对象传给jwt jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER payload = jwt_payload_handler(user) token = jwt_encode_handler(payload) user.token = token # 把token加到user字段(不会存入db[db也没有这个字段],而是存在前端) return user # 这个返回值,就是 RegisterView 的返回值,即返回注册用户对象
-
-
注册需要注意的问题小结
-
手机图形验证码的加载
-
用户名/手机号码是否重复
-
发送短信celery处理
- 申请短信服务商
-
这是标准的新增行为,使用 CreateAPIView,复杂的逻辑全都扔在序列化器处理
- 重写create主要做两件事情
- 数据存入db
- 签发jwt
- 重写create主要做两件事情
-
返回值(总共4个字段)
- JWT自定义返回三个字段: id,token,username
- 序列化器多返回一个mobile
- 后端是返回user对象(实质就python dict,里面包裹用户数据),DRF帮助我们JSON返回给前端
登录逻辑的实现
登录功能
-
请求方式
请求方法 请求地址 post http://127.0.0.1:8000/authorizations/ -
请求参数
参数名 类型 是否必传 说明 username string 是 用户名或手机号码 password string 是 密码 image_code string 是 图形验证码 image_code_id string 是 随机码 -
响应成功结果:JSON
字段 值 说明 user_id 数字 用户ID username 字符串 用户名 token xxx.xxx.xxx jwt校验字符串 -
后端接口注意事项
- 登录逻辑继承的是 ObtainJSONWebToken(这是jwt提供的视图类) - ObtainJSONWebToken 最终继承的是 APIView - 但是,还需要实现序列化器怎么破 - 所以源码模仿了部分GenericAPIView的逻辑,自己实现序列化器逻辑 - 由于加了验证码逻辑,故需要重写序列化器,在原先的基础上,先判断验证码的校验逻辑 - 字段的校验 - 重写create() - db字段的保存 - 最重要点: jwt的签发逻辑扔在这里执行,给user对象绑定token返回给前端存储 - 想要实现手机号码也能登录,需修改django默认的认证后端,在原先的基础上,加入手机号码的校验 并在settings里面配置自定义的认证后端
-
小结流程: 前端提交'用户名'&'密码'&'验证码'三个字段
-
验证失败,提示错误信息: 返回400状态码
-
验证成功,为用户签发jwt,返回给用户三个字段'token','username','user_id'
前端接收到这三个字段,渲染
-
后端接口设计
- urls
urlpatterns = [
......
# 登录
# url(r'^authorizations/$', obtain_jwt_token), # jwt内部已经实现登录视图,调用即可
# 继承原有的逻辑,加点东东
url(r'^authorizations/$', views.UserAuthorizeView.as_view()),
]
- jwt 登录视图源码解析:只返回了token,我们还需要返回 username/user_id
......
class JSONWebTokenAPIView(APIView):
permission_classes = ()
authentication_classes = ()
def get_serializer_context(self):
......
def get_serializer_class(self):
......
def get_serializer(self, *args, **kwargs):
......
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
user = serializer.object.get('user') or request.user
token = serializer.object.get('token')
# 关注之处......重写此方法即可
response_data = jwt_response_payload_handler(token, user, request)
response = Response(response_data)
......
return response
# 如果校验不通过,返回400错误码
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
......
def jwt_response_payload_handler(token, user=None, request=None):
"""
自定义jwt认证成功返回数据,这里只返回token,我们给它加点数据即可满足需求
"""
return {
'token': token,
}
......
# scripts.jwt_auth
def jwt_response_payload_handler(token, user=None, request=None):
"""
自定义jwt认证成功返回数据
"""
return {
'token': token,
'user_id': user.id, # 新增两个字段
'username': user.username
}
- 配置方法分析
......
# 碰到这种写法,意味我们改写导包路径,导入我们自定义的方法路径即可
jwt_response_payload_handler = api_settings.JWT_RESPONSE_PAYLOAD_HANDLER
- settings
#---------JWT-----------------#
JWT_AUTH = {
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1), # 令牌有效期1天
'JWT_RESPONSE_PAYLOAD_HANDLER': 'utils.auth.jwt_response_payload_handler', # 重写导包路径
}
-
视图(继承原先的jwt逻辑,添加验证码逻辑)
### users.views ...... class UserAuthorizeView(ObtainJSONWebToken): # 继承jwt原先的视图类 serializer_class = LoginSerializer # 增加验证码校验逻辑 def post(self, request, *args, **kwargs): # post方法和源码一模一样,先预留位置(这里先不重写也可以) serializer = self.get_serializer(data=request.data) if serializer.is_valid(): user = serializer.object.get('user') or request.user token = serializer.object.get('token') response_data = jwt_response_payload_handler(token, user, request) response = Response(response_data) if api_settings.JWT_AUTH_COOKIE: expiration = (datetime.utcnow() + api_settings.JWT_EXPIRATION_DELTA) response.set_cookie(api_settings.JWT_AUTH_COOKIE, token, expires=expiration, httponly=True) # merge_cookie_to_redis(request,user,response) return response return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
序列化器验证码的校验
### users.serializers class LoginSerializer(JSONWebTokenSerializer): # 继承原有的校验逻辑 def validate(self, attrs): # 在原先的 validate()方法基础上,优先校验验证码 # 优先图形验证码校验 image_code_id = self.initial_data.get('image_code_id',None) image_code = self.initial_data.get('image_code',None) if not image_code or not image_code: raise serializers.ValidationError('验证码不能空') # 如下处理方式行不通,正常响应了 # return Response({'msg': '验证码不能为空', 'status_code': 1004}, status=status.HTTP_400_BAD_REQUEST) redis_conn = get_redis_connection('verify_codes') redis_byte_text = redis_conn.get(image_code_id) if not redis_byte_text: raise serializers.ValidationError('验证码不存在或已过期') # return Response({'msg': '验证码不存在或已过期', 'status_code': 1014}, status=status.HTTP_400_BAD_REQUEST) if image_code.lower() != redis_byte_text.decode('utf-8').lower(): raise serializers.ValidationError('验证码错误') # return Response({'msg': '验证码错误', 'status_code': 1024}, status=status.HTTP_400_BAD_REQUEST) # 以下代码照抄源码即可 credentials = { self.username_field: attrs.get(self.username_field), 'password': attrs.get('password') } if all(credentials.values()): user = authenticate(**credentials) if user: if not user.is_active: msg = _('User account is disabled.') raise serializers.ValidationError(msg) payload = jwt_payload_handler(user) return { 'token': jwt_encode_handler(payload), 'user': user } else: msg = _('Unable to log in with provided credentials.') raise serializers.ValidationError(msg) else: msg = _('Must include "{username_field}" and "password".') msg = msg.format(username_field=self.username_field) raise serializers.ValidationError(msg)
前端逻辑(login.html的html&&css 新增验证码结构和样式)
### login.js
// 表单提交
on_submit: function () {
this.check_username();
this.check_pwd();
this.check_image_code()
if (this.error_username == false && this.error_pwd == false && this.error_image_code == false) {
axios.post(this.host + '/authorizations/', {
username: this.username,
password: this.password,
image_code: this.image_code, // 新增验证码ID和用户输入的文本
image_code_id: this.image_code_id
}, {
responseType: 'json',
withCredentials: true
})
.then(response => {
// 使用浏览器本地存储保存token
if (this.remember) {
// 记住登录
sessionStorage.clear();
localStorage.token = response.data.token;
localStorage.user_id = response.data.user_id;
localStorage.username = response.data.username;
} else {
// 未记住登录
localStorage.clear();
sessionStorage.token = response.data.token;
sessionStorage.user_id = response.data.user_id;
sessionStorage.username = response.data.username;
}
// 跳转页面
var return_url = this.get_query_string('next');
if (!return_url) {
return_url = '/index.html';
}
location.href = return_url;
})
.catch(error => {
if (error.response.status == 400) {
// 处理校验错误
if(error.response.data.non_field_errors[0].includes('验证码')){
this.error_image_code_message = error.response.data.non_field_errors[0];
this.error_image_code = true;
}else {
this.error_pwd_message = '用户名或密码错误';
this.error_pwd = true;
}
} else {
this.error_pwd_message = '服务器错误';
this.error_pwd = true;
}
})
}
},
登录逻辑小结
- 如果仅仅提交用户名和密码,views逻辑十分简单,也不需要序列化器
- 加上验证码逻辑以后,做的事情多了一些,需要继承源码的基础上,再作处理
使用手机号码/用户的都可以登录
- JWT扩展的登录视图,在收到用户名与密码时,也是调用Django的认证系统中提供的authenticate()来检查用户名与密码是否正确
- 我们可以通过修改Django认证系统的认证后端(主要是authenticate方法)来支持登录账号既可以是用户名也可以是手机
- 修改Django认证系统的认证后端需要继承django.contrib.auth.backends.ModelBackend,并重写authenticate方法
-
authenticate(self, request, username=None, password=None, **kwargs)方法的参数说明:
- request 本次认证的请求对象
- username 本次认证提供的用户账号
- password 本次认证提供的密码
-
我们想要让用户既可以以用户名登录,也可以以手机号登录,那么对于authenticate方法而言,username参数即表示用户名或者手机号
重写authenticate方法的思路:
- 根据username参数查找用户User对象,username参数可能是用户名,也可能是手机号
- 若查找到User对象,调用User对象的check_password方法检查密码是否正确
### 新建: scripts.django_auth.py
from users.models import UserInfo
from django.contrib.auth.backends import ModelBackend
class UsernameMobileAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
# 验证username是手机号还是用户名
# # 1.方法1
# try:
# if re.match(r'^1[3-9]\d{9}$', username):
# # 如果是手机号,则与mobile属性对比
# user = User.objects.get(mobile=username)
# else:
# # 如果是用户名,则与username属性对比
# user = User.objects.get(username=username)
# except:
# return None
# else:
# # 如果查询到用户对象,则判断密码是否正确
# if user.check_password(password):
# return user
# else:
# return None
# 优化-1
try:
# 如果是手机号,则与mobile属性对比
user = UserInfo.objects.get(mobile=username)
except:
try:
# 如果是用户名,则与username属性对比
user = UserInfo.objects.get(username=username)
except:
return None
# 如果查询到用户对象,则判断密码是否正确
if user.check_password(password):
return user
else:
return None
- 在配置文件中告知Django使用我们自定义的认证后端
AUTHENTICATION_BACKENDS = [
'scriptss.django_auth.UsernameMobileAuthBackend',
]
- django内置的 authenticate()方法源码
......
class ModelBackend(BaseBackend):
"""
Authenticates against settings.AUTH_USER_MODEL.
"""
def authenticate(self, request, username=None, password=None, **kwargs):
......
'''
- 获取 user 实例对象
- 若出错,则给user设置密码,流程就此终止
- 若成功获取实例对象,那么校验密码是否正确,并检查该实例是否被'激活',返回user
- 自定义我们自己的逻辑,则校验用户名和密码是否都正确
- 都正确则返回user
- 错误就模仿源码,返回 None
'''
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760).
UserModel().set_password(password)
else:
if user.check_password(password) and self.user_can_authenticate(user):
return user
- 现在,可以用手机号登录了
前端实现登录后的跳转回原页面
- 演示场景
- 首页
- 用户个人中心
- 登录页
- 当从首页点击'用户个人中心'后,跳转到登录页,登录验证无误以后,页面跳转到'用户个人中心'
- url演示: http://127.0.0.1:8080/login.html?next=/user_center_info.html
- 实现思路
- 用户个人中心页面添加token判断
- 验证token和user_id是否都有
- 校验通过,就正常返回页面
- 如果校验不通过,就携带当前url路径,向login页面发起请求
......
<!--写在head里面,优先执行-->
<head>
......
<script>
var user_id = sessionStorage.user_id || localStorage.user_id;
var token = sessionStorage.token || localStorage.token;
if (!(user_id && token)) {
// 带着当前路径,作为查询字符串,向login发起请求
location.href = '/login.html?next=/user_center_info.html';
}
</script>
</head>
- 登录页的逻辑: http://127.0.0.1:8080/login.html?next=/user_center_info.html
......
// 获取url路径参数
get_query_string: function (name) {
var reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i');
var r = window.location.search.substr(1).match(reg);
if (r != null) {
return decodeURI(r[2]);
}
return null;
},
......
// 前提是登录成功,才有以下判断
// 跳转页面,获取查询字符串,进行判断,有就跳转,没有就转到首页
var return_url = this.get_query_string('next');
if (!return_url) {
return_url = '/index.html';
}
location.href = return_url;