V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
37Y37
V2EX  ›  Blogger

Django+JWT 实现 Token 认证

  •  
  •   37Y37 · 2019-01-24 10:33:03 +08:00 · 6556 次点击
    这是一个创建于 2159 天前的主题,其中的信息可能已经有所发展或是发生改变。

    对外提供 API 不用 django rest framework ( DRF )就是旁门左道吗?

    基于 Token 的鉴权机制越来越多的用在了项目中,尤其是对于纯后端只对外提供 API 没有 web 页面的项目,例如我们通常所讲的前后端分离架构中的纯后端服务,只提供 API 给前端,前端通过 API 提供的数据对页面进行渲染展示或增加修改等,我们知道 HTTP 是一种无状态的协议,也就是说后端服务并不知道是谁发来的请求,那么如何校验请求的合法性呢?这就需要通过一些方式对请求进行鉴权了

    先来看看传统的登录鉴权跟基于 Token 的鉴权有什么区别

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

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

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

    JSON Web Token ( JWT)是目前 Token 鉴权机制下最流行的方案,网上关于 JWT 的介绍有很多,这里不细说,只讲下 Django 如何利用 JWT 实现对 API 的认证鉴权,搜了几乎所有的文章都是说 JWT 如何结合 DRF 使用的,如果你的项目没有用到 DRF 框架,也不想仅仅为了鉴权 API 就引入庞大复杂的 DRF 框架,那么可以接着往下看

    我的需求如下:

    1. 同一个 view 函数既给前端页面提供数据,又对外提供 API 服务,要同时满足基于账号密码的验证和 JWT 验证
    2. 项目用了 Django 默认的权限系统,既能对账号密码登录的进行权限校验,又能对基于 JWT 的请求进行权限校验

    PyJWT 介绍

    要实现上边的需求 1,我们首先得引入 JWT 模块,python 下有现成的 PyJWT 模块可以直接用,先看下 JWT 的简单用法

    安装 PyJWT
    $ pip install pyjwt
    
    利用 PyJWT 生成 Token
    >>> import jwt
    >>> encoded_jwt = jwt.encode({'username':'运维咖啡吧','site':'https://ops-coffee.cn'},'secret_key',algorithm='HS256')
    

    这里传了三部分内容给 JWT,

    第一部分是一个 Json 对象,称为 Payload,主要用来存放有效的信息,例如用户名,过期时间等等所有你想要传递的信息

    第二部分是一个秘钥字串,这个秘钥主要用在下文 Signature 签名中,服务端用来校验 Token 合法性,这个秘钥只有服务端知道,不能泄露

    第三部分指定了 Signature 签名的算法

    查看生成的 Token
    >>> print(encoded_jwt)
    b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Ilx1OGZkMFx1N2VmNFx1NTQ5Nlx1NTU2MVx1NTQyNyIsInNpdGUiOiJodHRwczovL29wcy1jb2ZmZWUuY24ifQ.fIpSXy476r9F9i7GhdYFNkd-2Ndz8uKLgJPcd84BkJ4'
    

    JWT 生成的 Token 是一个用两个点(.)分割的长字符串

    点分割成的三部分分别是 Header 头部,Payload 负载,Signature 签名:Header.Payload.Signature

    JWT 是不加密的,任何人都可以读的到其中的信息,其中第一部分 Header 和第二部分 Payload 只是对原始输入的信息转成了 base64 编码,第三部分 Signature 是用 header+payload+secret_key 进行加密的结果

    可以直接用 base64 对 Header 和 Payload 进行解码得到相应的信息

    >>> import base64
    >>> base64.b64decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9')
    b'{"typ":"JWT","alg":"HS256"}'
    
    >>> base64.b64decode('eyJ1c2VybmFtZSI6Ilx1OGZkMFx1N2VmNFx1NTQ5Nlx1NTU2MVx1NTQyNyIsInNpdGUiOiJodHRwczovL29wcy1jb2ZmZWUuY24ifQ==')
    # 这里最后加=的原因是 base64 解码对传入的参数长度不是 2 的对象,需要再参数最后加上一个或两个等号=
    

    因为 JWT 不会对结果进行加密,所以不要保存敏感信息在 Header 或者 Payload 中,服务端也主要依靠最后的 Signature 来验证 Token 是否有效以及有无被篡改

    解密 Token
    >>> jwt.decode(encoded_jwt,'secret_key',algorithms=['HS256'])
    {'username': '运维咖啡吧', 'site': 'https://ops-coffee.cn'}
    

    服务端在有秘钥的情况下可以直接对 JWT 生成的 Token 进行解密,解密成功说明 Token 正确,且数据没有被篡改

    当然我们前文说了 JWT 并没有对数据进行加密,如果没有 secret_key 也可以直接获取到 Payload 里边的数据,只是缺少了签名算法无法验证数据是否准确,pyjwt 也提供了直接获取 Payload 数据的方法,如下

    >>> jwt.decode(encoded_jwt, verify=False)
    {'username': '运维咖啡吧', 'site': 'https://ops-coffee.cn'}
    

    Django 案例

    Django 要兼容 session 认证的方式,还需要同时支持 JWT,并且两种验证需要共用同一套权限系统,该如何处理呢?我们可以参考 Django 的解决方案:装饰器,例如用来检查用户是否登录的login_required和用来检查用户是否有权限的permission_required两个装饰器,我们可以自己实现一个装饰器,检查用户的认证模式,同时认证完成后验证用户是否有权限操作

    于是一个auth_permission_required的装饰器产生了:

    from django.conf import settings
    from django.http import JsonResponse
    from django.contrib.auth import get_user_model
    from django.core.exceptions import PermissionDenied
    
    UserModel = get_user_model()
    
    
    def auth_permission_required(perm):
        def decorator(view_func):
            def _wrapped_view(request, *args, **kwargs):
                # 格式化权限
                perms = (perm,) if isinstance(perm, str) else perm
    
                if request.user.is_authenticated:
                    # 正常登录用户判断是否有权限
                    if not request.user.has_perms(perms):
                        raise PermissionDenied
                else:
                    try:
                        auth = request.META.get('HTTP_AUTHORIZATION').split()
                    except AttributeError:
                        return JsonResponse({"code": 401, "message": "No authenticate header"})
    
                    # 用户通过 API 获取数据验证流程
                    if auth[0].lower() == 'token':
                        try:
                            dict = jwt.decode(auth[1], settings.SECRET_KEY, algorithms=['HS256'])
                            username = dict.get('data').get('username')
                        except jwt.ExpiredSignatureError:
                            return JsonResponse({"status_code": 401, "message": "Token expired"})
                        except jwt.InvalidTokenError:
                            return JsonResponse({"status_code": 401, "message": "Invalid token"})
                        except Exception as e:
                            return JsonResponse({"status_code": 401, "message": "Can not get user object"})
    
                        try:
                            user = UserModel.objects.get(username=username)
                        except UserModel.DoesNotExist:
                            return JsonResponse({"status_code": 401, "message": "User Does not exist"})
    
                        if not user.is_active:
                            return JsonResponse({"status_code": 401, "message": "User inactive or deleted"})
    
                        # Token 登录的用户判断是否有权限
                        if not user.has_perms(perms):
                            return JsonResponse({"status_code": 403, "message": "PermissionDenied"})
                    else:
                        return JsonResponse({"status_code": 401, "message": "Not support auth type"})
    
                return view_func(request, *args, **kwargs)
    
            return _wrapped_view
    
        return decorator
    

    在 view 使用时就可以用这个装饰器来代替原本的login_requiredpermission_required装饰器了

    @auth_permission_required('account.select_user')
    def user(request):
        if request.method == 'GET':
            _jsondata = {
                "user": "ops-coffee",
                "site": "https://ops-coffee.cn"
            }
            
            return JsonResponse({"state": 1, "message": _jsondata})
        else:
            return JsonResponse({"state": 0, "message": "Request method 'POST' not supported"})
    

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

    class User(AbstractBaseUser, PermissionsMixin):
        create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
        update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
        username = models.EmailField(max_length=255, unique=True, verbose_name='用户名')
        fullname = models.CharField(max_length=64, null=True, verbose_name='中文名')
        phonenumber = models.CharField(max_length=16, null=True, unique=True, verbose_name='电话')
        is_active = models.BooleanField(default=True, verbose_name='激活状态')
    
        objects = UserManager()
    
        USERNAME_FIELD = 'username'
        REQUIRED_FIELDS = []
    
        def __str__(self):
            return self.username
    
        @property
        def token(self):
            return self._generate_jwt_token()
    
        def _generate_jwt_token(self):
            token = jwt.encode({
                'exp': datetime.utcnow() + timedelta(days=1),
                'iat': datetime.utcnow(),
                'data': {
                    'username': self.username
                }
            }, settings.SECRET_KEY, algorithm='HS256')
    
            return token.decode('utf-8')
    
        class Meta:
            default_permissions = ()
    
            permissions = (
                ("select_user", "查看用户"),
                ("change_user", "修改用户"),
                ("delete_user", "删除用户"),
            )
    

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

    >>> from accounts.models import User
    >>> u = User.objects.get(username='[email protected]')
    >>> u.token
    'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1NDgyMjg3NzksImlhdCI6MTU0ODE0MjM3OSwiZGF0YSI6eyJ1c2VybmFtZSI6ImFkbWluQDE2My5jb20ifX0.akZNU7t_z2kwPxDJjmc-QxtNdICK0yhnwWmKxqqXKLw'
    

    生成的 Token 给到客户端,客户端就可以拿这个 Token 进行鉴权了

    >>> import requests
    >>> token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1NDgyMjg4MzgsImlhdCI6MTU0ODE0MjQzOCwiZGF0YSI6eyJ1c2VybmFtZSI6ImFkbWluQDE2My5jb20ifX0.oKc0SafgksMT9ZIhTACupUlz49Q5kI4oJA-B8-GHqLA'
    >>>
    >>> r = requests.get('http://localhost/api/user', headers={'Authorization': 'Token '+token})
    >>> r.json()
    {'username': '[email protected]', 'fullname': '运维咖啡吧', 'is_active': True}
    

    这样一个auth_permission_required方法就可以搞定上边的全部需求了,简单好用。


    长按关注公众号查看更多原创文章

    如果你觉得文章对你有帮助,请点右下角 [好看] 。如果你觉得读的不尽兴,推荐阅读以下文章:

    3 条回复    2020-01-19 17:08:56 +08:00
    kayseen
        1
    kayseen  
       2020-01-19 15:29:29 +08:00
    楼主这篇文章写的真的很有用,请问下有没有 django 的自定义权限认证的文章呢,

    还有就是上面的文章中的`if not request.user.has_perms(perms)`没有看懂是怎么使用的...
    37Y37
        2
    37Y37  
    OP
       2020-01-19 16:22:37 +08:00
    @kayseen 博客里有两篇关于 django 认证的文章看看 https://ops-coffee.cn
    kayseen
        3
    kayseen  
       2020-01-19 17:08:56 +08:00
    @37Y37
    写的文章真的很棒, 十分感谢楼主~
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1113 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 18:00 · PVG 02:00 · LAX 10:00 · JFK 13:00
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.