请求验证码

  • 思路设计
- 页面加载完成时,前端生成随机码 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错误,前端只会提示'验证码错误'
      而实际上还有一个'短信发送太频繁'的错误,对用户不够友好
......
.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模型的相关信息,例如usernameuser_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
  • 返回值(总共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;