Django在做后台系统过程中,我们通常都会为view函数添加@login_required 装饰器,这个装饰器的主要作用就是在用户访问这个方法时,检查用户是否已经成功登陆,如果没有则重定向到登陆页面

登陆页面地址通过settings.LOGIN_URL来获取的,默认为/accounts/login/

from django.contrib.auth.decorators import login_required


@login_required(login_url='/login/')
def home(request):
    return JsonResponse({data': 'ops'})


Middleware

通常对于一个后台系统来说,每一个页面都需要登陆才能访问,这样我们就需要给每一个view方法添加@login_required装饰器(咱之前写flask的时候就是这么搞的,现在觉得很蠢),那么有没有简单优雅一点的方式呢 可以通过Middleware中间件来实现中间件位于用户请求和程序响应之间,当用户访问一个url之后并不是直接交给view去处理,而是先经过中间件处理,然后再到view,路线是这样的

uer-->middleware-->view,所以针对全局所有view的操作就非常适合放在中间件里去处理

Django的中间件都定义在setttings的MIDDLEWARE的配置下

但是login_required这种是基于seesion的一种认证方式,咱不用这个,咱们用jwt


传统的登录鉴权和基于token的鉴权有什么区别

以Django的账号密码登录为例来说明传统的验证鉴权是怎么工作的,当我们登录页面输入账号密码提交表单后,会发送请求给服务器,服务器对发送过来的账号密码进行验证鉴权,验证鉴权通过后,把用户信息记录在服务端(django_session表中),同时返回给浏览器一个sessionid用来唯一标识这个用户,浏览器将sessionid保存在cookie中,之后浏览器的每次请求都一并将sessionid发送给服务器,服务器根据sessionid与记录的信息来做对比以验证身份

Token的鉴权方式就清晰很多了,客户端用自己的账号密码进行登录,服务端验证鉴权,验证鉴权通过生成Token返回给客户端,之后客户端每次请求都将Token放在header里一并发送,服务端收到请求时校验Token以确定访问者身份

session的主要目的是给无状态的http状态添加状态保持,通常在浏览器作为客户端的情况下比较通用,而Token的主要目的是为了鉴权,同时又不需要考虑CSRF防护以及跨域的问题,所以更多的用在专门给第三方提供的API的情况下,客户端请求无论是浏览器发起还是其他的程序发起都能很好的支持,所以目前基于Token的鉴权机制几乎已经成了前后端分离架构或者对外API访问的鉴权标准,得到广泛使用


关于JWT

网上关于jwt的介绍有很多,这里不细说,可以参考我往期的文章:浅谈JWT和实战_彭阳的技术博客_51CTO博客

只讲下django如何利用JWT实现自定用户认证,搜了几乎所有的文章都是说JWT如何结合DRF使用的,如果你的项目没有用到DRF框架,也不想仅仅为了鉴权API就引入庞大复杂的DRF框架,那么可以接着往下看


演示

pip install PyJWT==2.3.0

settings.py   

INSTALLED_APPS = [
    'account'
]

MIDDLEWARE = [
    # "django.contrib.sessions.middleware.SessionMiddleware",
    'django.middleware.security.SecurityMiddleware',
    'django.middleware.common.CommonMiddleware',
    # 用户认证的拦截器
    'common.middleware.AuthenticationMiddleware',
]
# 自定义用户模型
AUTH_USER_MODEL = 'account.User'

创建模型

account/models.py

from django.contrib.auth.hashers import make_password, check_password
from django.db import models
from django.utils.translation import gettext_lazy as _
from channels.db import database_sync_to_async
from itertools import chain
from typing import List, Dict, Any


class AsyncManage(models.Manager):

    async def filter_async(self, *args, **kwargs):
        return await database_sync_to_async(list)(super().filter(*args, **kwargs))

    @database_sync_to_async
    def get_async(self, *args, **kwargs):
        return super().get(*args, **kwargs)

    @database_sync_to_async
    def create_async(self, **kwargs):
        return super(AsyncManage, self).create(**kwargs)

    @database_sync_to_async
    def update_async(self, **kwargs):
        return super(AsyncManage, self).update(**kwargs)

    @database_sync_to_async
    def first_async(self, *args, **kwargs):
        return super().filter(*args, **kwargs).first()


class CommonModelMixin(models.Model):
    objects = AsyncManage()

    __slots__ = ()

    class Meta:
        abstract = True

    class CustomMeta:
        """
        自定义变量元类
        Meta元类不允许添加新的变量,所以有新的变量可以再这里添加
        """
        # to_json()方法中默认排除的字段名集合
        default_excludes = ['pk']

    def to_json(self, excludes: List[str] = None, includes: List[str] = None) -> Dict[str, Any]:
        """
        Serialize the model instance to a Python dictionary.

        :param excludes: list of field names to exclude from the serialized data.
        :param includes: list of field names to include in the serialized data.
        :return: a dictionary representation of the model instance.
        """
        opts = self._meta
        # 排除字段集合
        excludes = set(excludes) if excludes else set()
        # 包含字段集合
        includes = set(includes) if includes else set()
        # 添加默认排除的字段
        excludes.update(self.CustomMeta.default_excludes)
        fields = [
            field.attname
            for field in chain(opts.private_fields, opts.concrete_fields)
            if field.attname not in excludes and (not includes or field.attname in includes)
        ]
        fields.extend(
            field
            for field in opts._property_names
            if (not excludes or field not in excludes) and (not includes or field in includes)
        )
        return {field: getattr(self, field) for field in fields}


class User(CommonModelMixin):
    username = models.CharField(
        max_length=100,
        verbose_name=_('用户名'),
        help_text=_('登录时所需的用户名')
    )
    nickname = models.CharField(
        max_length=100,
        verbose_name=_('昵称'),
        help_text=_('用户使用的昵称')
    )
    password_hash = models.CharField(
        max_length=100,
        verbose_name=_('密码'),
        help_text=_('非明文密码,经过哈希算法加密过的密码')
    )
    phone = models.CharField(
        max_length=11,
        null=True,
        verbose_name=_('电话号码'),
        help_text=_('用户有电话号码时,会需要填验证码')
    )
    type = models.CharField(
        max_length=20,
        default='default',
        verbose_name=_('用户类型'),
        help_text=_('1:正常用户 2:LDAP用户.LDAP认证方式未开放')
    )
    is_supper = models.BooleanField(
        default=False,
        verbose_name=_('管理员'),
        help_text=_('账号是否为管理员')
    )
    is_active = models.BooleanField(
        default=True,
        verbose_name=_('是否激活'),
        help_text=_('用户是否激活')
    )
    access_token = models.CharField(
        max_length=32,
        verbose_name=_('私人令牌'),
        help_text=_('前端发送请求时使用该字段进行校验')
    )
    token_expired = models.IntegerField(
        null=True,
        verbose_name=_("令牌过期时间"),
        help_text=_("未使用")
    )
    last_login = models.CharField(max_length=20, verbose_name="最近登录时间")
    last_ip = models.CharField(max_length=50, verbose_name="最近登录ip")
    ssh_config = models.TextField(null=True, default="{}")
    game_permissions = models.TextField(null=True, default="[]")

    USERNAME_FIELD = "username"

    def __repr__(self):
        return self.nickname

    @staticmethod
    def make_password(plain_password: str) -> str:
        return make_password(plain_password, hasher='pbkdf2_sha256')

    def verify_password(self, plain_password: str) -> bool:
        return check_password(plain_password, self.password_hash)

    class Meta:
        db_table = 'users'
        ordering = ('-id',)

用户认证中间件

common/middleware.py

import logging
import time

import jwt
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.deprecation import MiddlewareMixin
from jwt import InvalidSignatureError, InvalidAlgorithmError, DecodeError, ExpiredSignatureError
from django.http import JsonResponse

logger = logging.getLogger(__name__)


class AuthenticationMiddleware(MiddlewareMixin):
    """
    自定义登录验证类
    """

    def process_request(self, request):
        # 当path为不需要登录验证时跳过验证
        if request.path in settings.AUTHENTICATION_EXCLUDES:
            return None
        # 获取头部的密钥
        token = request.headers.get('token') or request.GET.get('token')
        # 获取真实ip
        x_real_ip = request.headers.get('x-real-ip', '')
        if not token:
            response = JsonResponse({"code": 401, "messages": "当前账号未登录", "data": None})
            response.status_code = 401
            return response
        # 验证JWT密钥
        try:
            data = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256', ])
        except (InvalidSignatureError, InvalidAlgorithmError, DecodeError):
            response = JsonResponse({"code": 401, "messages": "当前账号未登录", "data": None})
            response.status_code = 401
            return response
        except ExpiredSignatureError:
            # sign过期
            response = JsonResponse({"code": 401, "messages": "账号登录信息已过期,请重新登录!", "data": None})
            response.status_code = 401
            return response

        # 验证密钥解密信息
        now = int(time.time())
        exp_time = data.get('exp', None)
        username = data.get('username', None)
        if not exp_time or exp_time < now:
            response = JsonResponse({"code": 401, "messages": "账号登录信息已过期,请重新登录!", "data": None})
            response.status_code = 401
            return response

        if not username:
            response = JsonResponse({"code": 401, "messages": "当前账号未登录", "data": None})
            response.status_code = 401
            return response

        user = get_user_model().objects.filter(username=username).first()
        if not user:
            response = JsonResponse({"code": 401, "messages": "当前账号未登录", "data": None})
            response.status_code = 401
            return response

        if x_real_ip == user.last_ip and user.is_active:
            request.user = user
            return None

        response = JsonResponse({"code": 401, "messages": "当前账号未登录", "data": None})
        response.status_code = 401
        return response

接下来就是迁移数据库


我们还需要一个生成用户Token的方法,通过给User model添加一个token的静态方法来处理

account/models.py

import jwt
import time
from django.conf import settings
from django.contrib.auth.hashers import make_password, check_password
from django.db import models
from django.utils.translation import gettext_lazy as _
from channels.db import database_sync_to_async
from itertools import chain
from typing import List, Dict, Any


class User(CommonModelMixin):
    username = models.CharField(
        max_length=100,
        verbose_name=_('用户名'),
        help_text=_('登录时所需的用户名')
    )
    nickname = models.CharField(
        max_length=100,
        verbose_name=_('昵称'),
        help_text=_('用户使用的昵称')
    )
    password_hash = models.CharField(
        max_length=100,
        verbose_name=_('密码'),
        help_text=_('非明文密码,经过哈希算法加密过的密码')
    )
    phone = models.CharField(
        max_length=11,
        null=True,
        verbose_name=_('电话号码'),
        help_text=_('用户有电话号码时,会需要填验证码')
    )
    type = models.CharField(
        max_length=20,
        default='default',
        verbose_name=_('用户类型'),
        help_text=_('1:正常用户 2:LDAP用户.LDAP认证方式未开放')
    )
    is_supper = models.BooleanField(
        default=False,
        verbose_name=_('管理员'),
        help_text=_('账号是否为管理员')
    )
    is_active = models.BooleanField(
        default=True,
        verbose_name=_('是否激活'),
        help_text=_('用户是否激活')
    )
    access_token = models.CharField(
        max_length=32,
        verbose_name=_('私人令牌'),
        help_text=_('前端发送请求时使用该字段进行校验')
    )
    token_expired = models.IntegerField(
        null=True,
        verbose_name=_("令牌过期时间"),
        help_text=_("未使用")
    )
    last_login = models.CharField(max_length=20, verbose_name="最近登录时间")
    last_ip = models.CharField(max_length=50, verbose_name="最近登录ip")
    ssh_config = models.TextField(null=True, default="{}")
    game_permissions = models.TextField(null=True, default="[]")

    USERNAME_FIELD = "username"

    def __repr__(self):
        return self.nickname

    @staticmethod
    def make_password(plain_password: str) -> str:
        return make_password(plain_password, hasher='pbkdf2_sha256')

    def verify_password(self, plain_password: str) -> bool:
        return check_password(plain_password, self.password_hash)

    @property
    def token(self):
        return self._generate_jwt_token()

    def _generate_jwt_token(self):
        payload = {'username': self.username, 'exp': int(time.time()) + 86400}
        token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256')
        return token

    class Meta:
        db_table = 'users'
        ordering = ('-id',)


添加自定义创建用户命令

account/management/commands/user.py

from django.core.cache import cache
from django.core.management.base import BaseCommand
from account.models import User


class Command(BaseCommand):
    help = '账户管理'

    def add_arguments(self, parser):
        parser.add_argument('action', type=str, help='执行动作')
        parser.add_argument('-u', required=False, help='账户名称')
        parser.add_argument('-p', required=False, help='账户密码')
        parser.add_argument('-n', required=False, help='账户昵称')
        parser.add_argument('-s', default=False, action='store_true', help='是否是超级用户(默认否)')

    def echo_success(self, msg):
        self.stdout.write(self.style.SUCCESS(msg))

    def echo_error(self, msg):
        self.stderr.write(self.style.ERROR(msg))

    def print_help(self, *args):
        message = '''
        账户管理命令用法:
            user add    创建账户,例如:user add -u admin -p 123 -n 管理员 -s
            user reset  重置账户密码,例如:user reset -u admin -p 123
            user enable 启用被禁用的账户,例如:user enable -u admin
        '''
        self.stdout.write(message)

    def handle(self, *args, **options):
        action = options['action']
        if action == 'add':
            if not all((options['u'], options['p'], options['n'])):
                self.echo_error('缺少参数')
                self.print_help()
            elif User.objects.filter(username=options['u']).exists():
                self.echo_error(f'已存在登录名为【{options["u"]}】的用户')
            else:
                User.objects.create(
                    username=options['u'],
                    nickname=options['n'],
                    password_hash=User.make_password(options['p']),
                    is_supper=options['s'],
                )
                self.echo_success('创建用户成功')
        elif action == 'enable':
            if not options['u']:
                self.echo_error('缺少参数')
                self.print_help()
            user = User.objects.filter(username=options['u']).first()
            if not user:
                return self.echo_error(f'未找到登录名为【{options["u"]}】的账户')
            user.is_active = True
            user.save()
            cache.delete(user.username)
            self.echo_success('账户已启用')
        elif action == 'reset':
            if not all((options['u'], options['p'])):
                self.echo_error('缺少参数')
                self.print_help()
            user = User.objects.filter(username=options['u']).first()
            if not user:
                return self.echo_error(f'未找到登录名为【{options["u"]}】的账户')
            user.password_hash = User.make_password(options['p'])
            user.save()
            self.echo_success('账户密码已重置')
        else:
            self.echo_error('未识别的操作')
            self.print_help()

django 关于自定义用户认证_中间件

可以直接通过用户对象来生成Token

django 关于自定义用户认证_django_02


请求测试

django 关于自定义用户认证_中间件_03

拿到Token请求测试

django 关于自定义用户认证_中间件_04