对外提供 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,我们首先得引入 JWT 模块,python 下有现成的 PyJWT 模块可以直接用,先看下 JWT 的简单用法
$ pip install pyjwt
>>> import jwt
>>> encoded_jwt = jwt.encode({'username':'运维咖啡吧','site':'https://ops-coffee.cn'},'secret_key',algorithm='HS256')
这里传了三部分内容给 JWT,
第一部分是一个 Json 对象,称为 Payload,主要用来存放有效的信息,例如用户名,过期时间等等所有你想要传递的信息
第二部分是一个秘钥字串,这个秘钥主要用在下文 Signature 签名中,服务端用来校验 Token 合法性,这个秘钥只有服务端知道,不能泄露
第三部分指定了 Signature 签名的算法
>>> 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 是否有效以及有无被篡改
>>> 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 要兼容 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_required
和permission_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='admin@ops-coffee.cn')
>>> 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': 'admin@ops-coffee.cn', 'fullname': '运维咖啡吧', 'is_active': True}
这样一个auth_permission_required
方法就可以搞定上边的全部需求了,简单好用。
如果你觉得文章对你有帮助,请点右下角 [好看] 。如果你觉得读的不尽兴,推荐阅读以下文章:
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.