Django Rest Framework
与JWT
身份验证
理论部分
这个技术是用来解决跨域情况下的用户登录与维持登录状态的问题。即处理跨域之后的身份验证问题。
Django
自带的auth
登录验证系统只能应用与于前端页面与后端页面在同一个网址(域)的情况下,也就是说当前后端分离,后端只提供API
给前端,前端通过API
提供的数据对页面进行渲染或增加修改的情况下,这种默认的登录验证系统就不起效果了,因为HTTP
是一种无状态的协议,也就是说后端服务并不知道是谁发来的请求,我们也就无法验证请求的合法性。我们一般写前后端分离的项目时,都是用ajax
向服务器端发起请求以获取后端API
的数据,这种方式是无法获取从服务器端返回回来的sessionid
的,从而就难以做身份验证。简单来说,就是传统的登录鉴权在前后端分离的情况下,无法将后端的sessionid
存到前端对应域名的cookie
里,因此无法做前后端分离的身份验证,cookie
无法做跨域。
传统的登录鉴权与基于Token
的鉴权区别
先来看看传统的登录鉴权跟基于Token
的鉴权有什么区别:
以Django
的账号密码登录为例来说明传统的验证鉴权方式是怎么工作的,当我们登录页面输入账号密码提交表单后,会发送请求给服务器,服务器对发送过来的账号密码进行验证鉴权,验证鉴权通过后,把用户信息记录在服务器端(django_session
表中),同时返回给浏览器一个sessionid
用来唯一标识这个用户,浏览器将sessionid
保存在cookie
中(它是属于http-only
的,是不能用js
读取到的),之后浏览器的每次请求都一并将sessionid
发送给服务器,服务器根据sessionid
与记录的信息做对比以验证身份。
Token
的鉴权方式就清晰很多了,客户端用自己的账号密码进行登录,服务端验证鉴权,验证鉴权通过生成Token
返回给客户端,之后客户端每次请求都将Token
放在header
里一并发送,服务端收到请求时校验Token
以确定访问者身份。
session
的主要目的是给无状态的HTTP
协议添加状态保持,通常在浏览器作为客户端的情况下比较通用。而Token
的主要目的是为了鉴权,同时又不需要考虑CSRF
防护以及跨域的问题,所以更多的用在专门给第三方提供API
的情况下,客户端请求无论是浏览器发起还是其他的程序发起都能很好的支持。所以目前基于Token
的鉴权机制几乎已经成了前后端分离架构或者对外提供API
访问的鉴权标准,得到广泛使用。
传统的用户登录认证中,因为
http
是无状态的,所以都是采用session
的认证方式。用户登录成功,服务端就会保存一个session
,也会给客户端一个sessionID
,以后客户端每次请求,都会携带这个sessionID
,服务端会会根据这个sessionID
来区分不同的用户。这种基于
cookie+session
的认证方式,随着服务从单服务到多服务,缺点就出来了,因为session
是存储在服务端,这样服务器的开销就会大起来了。并且session
标示丢失,就可能出现安全问题CSRF
(跨站请求伪造)。扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
Token
字面意思是令牌,功能跟Session
类似,也是用于验证用户信息的,Token
是服务端生成的一串字符串,当客户端发送登录请求时,服务器便会生成一个Token
并将此Token
返回给客户端,作为客户端进行请求的一个标识,以后客户端只需带上这个Token
前来请求数据即可,无需再次带上用户名和密码。与session
的不同之处在于,session
是将用户信息存储在服务器中保持用户的请求状态,而Token
在服务器端不需要存储用户的登录记录,客户端每次向服务端发送请求的时候都会带上服务端发给的Token
,服务端收到请求后去验证客户端请求里面带着Token
,如果验证成功,就向客户端返回请求的数据。
一些参考文章:
Django-rest-framework
的JWT
登录和认证
基于Token 的身份验证流程
- 客户端使用用户名跟密码请求登录
- 服务端收到请求开始验证用户名与密码
- 验证成功后,服务端生成一个
Token
(可以理解为一种加密算法,一般为有效信息 + 一个加密字符串)并把这个Token
发送给客户端 - 客户端收到
Token
以后可以把它存储起来,可以存放在Cookie
里或者Local Storage
里 - 客户端再次向服务端请求资形式源的时候携带服务端生成的
Token
发送给服务器 - 服务端收到请求,然后去验证客户端请求里面携带的
Token
,如果验证成功(如读取里面的user_id
信息从而知道是哪一个用户传过来的),就向客户端返回请求的数据,否则拒绝请求
localStorage、cookie和sessionStorage的区别
JWT
的构成
JSON Web Token(JWT)
由三部分组成,这些部分由点(.
)分隔,分别是header
(头部),payload
(有效负载)和signature
(签名)。
如下示例为一个JWT
:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsInR5cGUiOiJqd3QifQ.eyJ1c2VybmFtZSI6InhqayIsImV4cCI6MTU4MjU0MjAxN30.oHdfcsUftJJob66e5mL1jLRpJwiG0i9MOD5gzM476eY
header
(头部):
JWT
的头部承载两部分:声明类型,声明加密算法
headers = {
"type":"jwt",
"alg":"HS256"
}
然后将头部进行base64
加密。(该加密是可以对称解密的),构成了第一部分
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsInR5cGUiOiJqd3QifQ
payload
(有效负载)
载荷就是存放有效信息的地方,这个名字像是特指飞机上承载的货品,这些有效信息包含三部分:
- 标准中注册声明(建议但不强制使用):
iss:jwt
签发者。sub:jwt
所面向的用户aud:
接收jwt
的一方exp:jwt
过期时间,这个过期时间必须大于签发时间nbf:
定义在什么时间之前,该jwt
都是可用的lat:jwt
的签发时间jti:jwt
的唯一身份表示,主要用来作为一次性token
,从而回避重放攻击。
- 公共的声明:
- 可以添加任何信息,一般添加用户相关信息。但不建议添加敏感信息,因为该部分在客户端可解密
- 私有的声明:
- 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为
base64
是对称解密的,意味着该部分信息可以归类为明文信息。
- 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为
{
"username": "xjk",
}
构成了第二部分:eyJ1c2VybmFtZSI6InhqayIsImV4cCI6MTU4MjU0MjAxN30
-
signature
(签名) -
jwt
的第三部分是一个签证信息,这个签证信息由三部分组成:- header(
base64
后的) - payload(
base64
后的) - secret(私钥)
- header(
- 将
header
和payload
使用Base64
编码生成一下再加入签名字符secret
(密码加盐)用(header
中声明的加密算法加密一遍,得到唯一的签名,用来防止其他人来篡改Token
中的信息。 signature = 加密算法(header + "." + payload, 密钥)
构成了第三部分:oHdfcsUftJJob66e5mL1jLRpJwiG0i9MOD5gzM476eY
签名的目的:最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的
JWT
的话,那么服务器端会判断出新的头部和负载形成的签名和JWT
附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。
Token
基本实现原理
Token
也称作令牌,是由服务器端返回的字符串,此字符串是通过base64
编码后得到的,编码前信息为json
,包括加密方法、需要公开的用户信息、签名也就是密文。服务器端只有开发者随机设置的密钥。而服务器端是将用户信息和密钥一起通过加密方法加密,将得到的密文与需要公开的用户信息与加密方法通过base64
编码为字符串。当客户端请求带上Token
,服务器端用同样方法构造Token
,相同则允许请求,不同则报401
错误。这个加密采用的加密算法,一般有此特点:从源串计算出加密串很好算,但从加密串算出源串一般不可能。
之后当客户端向服务端发起请求时,只需要携带Token
给服务端,服务端根据Token
中的用户有效信息加上私钥再做一遍加密算法,看看结果是否与Token
中的“加密之后的结果”一致,若一致说明验证成功。
通过
DRF simple jw
t构造的jwt
会返回两个值,分别是access
和refresh
,access
就是上文我们所说的Token
,refresh
是用来是刷新access
串,refresh
请求每次都是用post
方法,post
方法的参数会在请求的body
里面,相对来说更加安全。access
和refresh
的过期时间不同,access
一般是5 分钟
,· 一般是14天
,因为有些get
方法也需要登录之后才能访问,意味着Token
会显式的显示在url
链接里,这样不太安全,因此Token
的有效期一般比较短。
配置DJango Rest Framework
与JWT
集成Django Rest Framework
安装
pip install djangorestframework
pip install pyjwt
pip install
#######
如果出现could not find a version....报错信息的话,可能是python解释器的路径与pip的安装路径不同,引用不到权限导致的,所以要修改一下指令:
python3 -m pip install djangorestframework
python3 -m pip install pyjwt
然后在settings.py
的INSTALLED_APPS
中添加rest_framework
#INSTALLED_APPS中添加注册信息
INSTALLED_APPS = [
'rest_framework',
...
'corsheaders',
]
Class-Based Views
from rest_framework.views import APIView
from rest_framework.response import Response
class SnippetDetail(APIView):
def get(self, request): # 查找
...
return Response(...)
def post(self, request): # 创建
...
return Response(...)
def put(self, request, pk): # 修改
...
return Response(...)
def delete(self, request, pk): # 删除
...
return Response(...)
集成jwt
验证
安装
pip install djangorestframework-simplejwt
然后我们需要告诉DRF
我们使用jwt
认证作为后台认证方案
在settings.py
中添加:
INSTALLED_APPS = [
...
'rest_framework_simplejwt',
...
]
# REST_FRAMEWORK中全局配置认证方式、权限方式。
# 如settings.py文件中没有REST_FRAMEWORK,请自主写入
REST_FRAMEWORK = {
...
# DEFAULT_AUTHENTICATION_CLASSES设置默认的认证类,这里用token,也可以设置session或自定义的认证 # 用户登陆认证方式
'DEFAULT_AUTHENTICATION_CLASSES': (
...
'rest_framework_simplejwt.authentication.JWTAuthentication',# 进行token认证
)
...
}
注意:INSTALLED_APPS
后都需要执行一个指令:python3 manage.py collectstatic
配置
在settings.py
中添加:
from datetime import timedelta # 导入datetime库生成时间参数
...
# SIMPLE_JWT是token配置项,参数很多,可查看官网https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html
SIMPLE_JWT = {
# ACCESS_TOKEN_LIFETIME设置token令牌有效时间
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
# REFRESH_TOKEN_LIFETIME设置token刷新令牌有效时间
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False,
'UPDATE_LAST_LOGIN': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY, # 注意这里的SECRET_KEY需要改成自己的字符串密钥(一个随机字符串)
'VERIFYING_KEY': None,
'AUDIENCE': None,
'ISSUER': None,
'JWK_URL': None,
'LEEWAY': 0,
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
'JTI_CLAIM': 'jti',
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}
添加获取jwt
和刷新jwt
的路由
jwt
已经帮我们实现了登录功能,不需要再自己手写了,直接引入就好了。
登录有两个API
:
api/token/
:获取Token
令牌api/token/refresh/
:刷新Token
令牌
在总项目下的urls.py
中加入自己定义的url
,给自己的新建的app
一个总的路由,inclue
中写入自己appname
中urls.py
的路径
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
...
# 类.as_view()将类转换为函数写法
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
...
]
访问对应的URL
可以得到rest framework work
给我们写好的调试页面:
我们需要用POST
的方法获取密钥
登出功能的实现只需要在客户端将用户的JWT
删掉即可
手动获取jwt
from rest_framework_simplejwt.tokens import RefreshToken
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
将jwt
验证集成到Django Channels
中
创建文件channelsmiddleware.py
"""General web socket middlewares
"""
from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import UntypedToken
from rest_framework_simplejwt.authentication import JWTTokenUserAuthentication
from channels.middleware import BaseMiddleware
from channels.auth import AuthMiddlewareStack
from django.db import close_old_connections
from urllib.parse import parse_qs
from jwt import decode as jwt_decode
from django.conf import settings
@database_sync_to_async
def get_user(validated_token):
try:
user = get_user_model().objects.get(id=validated_token["user_id"])
# return get_user_model().objects.get(id=toke_id)
return user
except:
return AnonymousUser()
class JwtAuthMiddleware(BaseMiddleware):
def __init__(self, inner):
self.inner = inner
async def __call__(self, scope, receive, send):
# Close old database connections to prevent usage of timed out connections
close_old_connections()
# Try to authenticate the user
try:
# Get the token
token = parse_qs(scope["query_string"].decode("utf8"))["token"][0]
# This will automatically validate the token and raise an error if token is invalid
UntypedToken(token)
except:
# Token is invalid
scope["user"] = AnonymousUser()
else:
# Then token is valid, decode it
decoded_data = jwt_decode(token, settings.SIMPLE_JWT["SIGNING_KEY"], algorithms=["HS256"])
# Get the user using ID
scope["user"] = await get_user(validated_token=decoded_data)
return await super().__call__(scope, receive, send)
def JwtAuthMiddlewareStack(inner):
return JwtAuthMiddleware(AuthMiddlewareStack(inner))
项目实现
后端
rest frame work
配合JWT
实现获取用户登录状态与登录信息。
appname/get_status.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
class InfoView(APIView):
permission_classes = ([IsAuthenticated])
def get(self,request):
# 获取当前用户信息
user = request.user
return Response({
'username': user.username,
'result': "success",
})
在url
路由里面引进该模块,可以把之前手写的login
与logout
模块去掉了,使用jwt
可以替代掉这两个功能。
appname/index.py
from django.urls import path
from backend.calculator.register import signup
from backend.calculator.get_status import InfoView
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path("register/", signup, name="calculator_register"),
path("get_status/", InfoView.as_view(),name="calculator_get_status"), #name是在后端渲染有用,前端渲染用不到
]
前端
前端是之前写的一个Web App
项目网站,里面目前只实现了个计算器(菜),不是本文的重点内容,因此就不强调了,只是展示如何在前端调用后端的API
,以实现登录功能与获取登录状态。
基本逻辑
每一次客户端向服务端发送请求,都需要携带access
(5分钟)与refresh
(14天),如果这个请求需要用到身份验证的话,都需要在后端做一个判断:如果refresh
过期了,需要用户重新登录;如果refresh
没有过期,access
过期了,那么就需要用refresh
的API
重新获取一个access
;如果refresh
没有过期,且access
也没有过期,则可以直接返回数据。
基本实现(完整版)
前端的客户端可以直接把access
与refresh
字段直接放在浏览器的local Storage
里面,每次可以从local Storage
里面读取这两个字段。
判断是否过期的方法:可以同时存一个创建时间,每次判断当前时间与创建时间的时间间隔有没有超过他的保质期,注意要预留时间间隔,防止传到服务器的时候有时间误差。
乞丐版展示
这里只是展示一下功能,因此逻辑可以不那么复杂,先搞个乞丐版的:用户每次刷新页面都需要重新登录,登录后将access
与refresh
字段直接都存到内存里,存完后写个周期函数,每隔4.5分钟向后端发送请求,重新获取一次access
。
这里前端用的是react
登录模块:login.jsx
handleClick = e => {
e.preventDefault();
if (this.state.username === "") {
this.setState({error_message: "用户名不能为空"});
} else if (this.state.password === "") {
this.setState({error_message: "密码不能为空"});
} else {
$.ajax({
url: "http://127.0.0.1:8000/token/",
type: "post",
data: {
username: this.state.username,
password: this.state.password,
},
dataType: 'json',
success: (resp) => {
console.log(resp);
this.props.set_token(resp.access,resp.refresh,this.state.username);
this.props.set_login(true);
},
error() {
this.setState({error_message: "用户名或密码错误"});
}
})
}
//console.log(this.state);
}
取消登录,直接将access
与refresh_token
去掉就好了
handleClick = () => {
this.props.set_token("","","");
this.props.set_login(false);
}
注册功能:register.jsx
handleClick = e => {
e.preventDefault();
$.ajax({
url: "http://127.0.0.1:8000/register/",
type: "get",
data: {
username: this.state.username,
password: this.state.password,
password_confirm: this.state.confirmed_password,
},
dataType: 'json',
success: resp => {
if (resp.result === "success") {
window.location.href="/calculator";
} else {
this.setState({error_message: resp.result});
}
}
});
console.log(this.state);
}
太强啦