0%

Django REST framework之JWT用户验证

在之前的 Django REST framework的token原理和验证登录 中,使用了drf自带的Token认证类rest_framework.authentication.TokenAuthentication 实现了Token认证,但是drf自带的Token认证有以下几个问题:

  1. drf自带的token验证方式,token值是存储在生成的 authtoken_token 表中,是放在一台服务器上的,若采用分布式的话,还需要做数据的同步操作。
  2. 相比于 django_session 的设置,没有expire_date属性(过期时间),将是永久有效的,这样的话,一旦泄露将造成很大的安全问题。
  3. 和django_session存储同样的问题,随着用户的增多,token值会占用服务器大量空间,同时也会加大数据库的查询压力,导致性能下降。

为了解决以上的问题,将使用第三方的开源库djangorestframework-jwt完成这一功能。

JWT原理

参考文章:前后端分离之JWT用户认证

摘要

  1. HTTP协议的无状态存储,需要提供用户登录的验证功能。
  2. 前后端分离系统的传统解决方式是:前端登录,后端根据用户信息生成一个token,并保存这个 token 和对应的用户id到数据库或Session中,接着把 token 传给用户,存入浏览器 cookie,之后浏览器请求带上这个cookie,后端根据这个cookie值来查询用户,验证是否过期。

JWT 是一个开放标准(RFC 7519),它定义了一种用于简洁,自包含的用于通信双方之间以 JSON 对象的形式安全传递信息的方法。JWT 可以使用 HMAC 算法或者是 RSA 的公钥密钥对进行签名。它具备两个特点:

  • 简洁(Compact)
    可以通过URL, POST 参数或者在 HTTP header 发送,因为数据量小,传输速度快
  • 自包含(Self-contained)
    负载中包含了所有用户所需要的信息,避免了多次查询数据库

组成

JWT由三部分组成

  • HEAD 头部: 包含了两部分,token 类型和采用的加密算法
  • Payload 负载: 我们存放信息的地方了,你可以把用户 ID 等信息放在这里,JWT 规范里面对这部分有进行了比较详细的介绍,常用的由 iss(签发者),exp(过期时间),sub(面向的用户),aud(接收方),iat(签发时间)。默认情况下负载中的内容使用的是 base64 编码方式,是可逆的,所以任何人都可以解读该部分的内容,因此不要构建隐私信息字段,存放保密信息,以防止信息泄露
  • Signature 签名: 保证内容没有被篡改。Signature 需要使用编码后的 header 和 payload 以及我们提供的一个密钥(存放在服务端),然后使用 header 中指定的签名算法(HS256)进行签名。生成的方式:
1
2
# python实现 
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) # secret密钥保存在服务器端,且不对外公布

完整的JWT构成:base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + Signature 签名

验证过程

由前端和后端共同完成
jwt认证过程

  • 前端进行登录,提供账号和密码在后端进行验证,后端对其验证通过后生成JWT,再发送给前端,前端保存在本地(一般是存储在本地的cookie中(推荐使用该种方式,只是使用cookie的存储机制),或者是保存在localStorage或sessionStorage上,退出登录时由前端删除保存的JWT即可)
  • 前端在跳转到登录页或者请求API的时候,会发送JWT,到达后端时被过滤器拦截进行JWT的验证(在Django可以交给中间件完成),验证通过之后请求被传送给view,未通过,返回错误信息(可以交给Django中间件中的process_exception函数处理),跳转到登录页面。(前面的所有的Django中间件的功能都将由即将使用到的 rest_framework_jwt.authentication.JSONWebTokenAuthentication 来完成)

JWT的优势

  • 完全使用算法进行验证,加密解密取得用户ID(相较于sessionid,后者还多了向服务器请求对应session_data的步骤),通过用户ID查询到用户信息,这意味着不需要存储token表,也不需要Django之中的session表,会减少服务器的存储压力
  • 适用于单点登录

JWT详细解读:https://www.cnblogs.com/DeadBoy/p/11481146.html

Django REST framework实现JWT的验证登录功能

使用第三方库djangorestframework-jwt完成验证,使用方法详见官方文档

安装后的配置过程

  1. 安装完成后,首先需要在REST_FRAMEWORK的DEFAULT_AUTHENTICATION_CLASSES 中配置
1
2
3
4
5
6
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
...
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
],
}

和drf自带的token功能使用的rest_framework.authentication.TokenAuthentication作用一致,是对用户post过来的token进行验证,验证完成后返回user。该设置只有在需要用户访问view,发送token进行验证的时候才需要设置,单纯的提供token值是不需要设置的(只需要完成步骤2)。
另外,这个值,最好不要在settings.py中进行设置,而只在需要使用的view中进行设置,因为有些页面属于公共资源,不需要验证token也可以进行访问。通过设置authentication_classes参数进行配置:

1
2
# views.py
authentication_classes = (JSONWebTokenAuthentication,)
  1. 在urls.py中添加url
1
2
3
4
5
6
from rest_framework_jwt.views import obtain_jwt_token

url(r'^api-token-auth/', obtain_jwt_token) # jwt的认证接口,是一个post请求。可以自定义url地址,如jwt-auth
# 这里注意区分url(r'^api-token-auth/', views.obtain_auth_token),这是drf自带的认证模式
# 自定义的jwt接口
url(r'^jwt-auth/', obtain_jwt_token)

此时访问http://xxx/jwt-auth,使用post请求传递用户信息(用户名和密码),验证通过之后就会直接返回一个jwt,采用的返回方式是k/v形式的json对象:

1
2
3
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3NjMyODgzLCJlbWFpbCI6IjEyMzRAcXEuY29tIn0.YIcAYZC3aY0AsoCyRDR78-1D-zdIGN3jHZHqgBWtjxo"
}

当传递的用于验证用户的信息(username和password)错误的时候,返回以下JSON数据:

1
2
3
4
5
{
"non_field_errors": [
"无法使用提供的认证信息登录。"
]
}
  1. 验证方式(注意和drf自带的token验证方式的不同书写)
    • 在http Headers中通过 Authorization: JWT <your_token>格式传递获得的JWT
    • 打断点,请求设置了 authentication_classes = (JSONWebTokenAuthentication,) 的view,查看request属性,可以发现用户存储在request中的user属性中,JWT值存储在auth属性中
    • JWT过期之后,返回的JSON格式数据
    1
    2
    3
    4
    5
    {
    "non_field_errors": [
    "无法使用提供的认证信息登录。"
    ]
    }

和前端vue登录功能的集成

直接将url(r'^api-token-auth/', obtain_jwt_token)的url地址改为url(r'^login/', obtain_jwt_token),变成登陆接口,前端向后端传递用户名和密码请求登陆,后端接受到请求后验证,验证通过返回JWT,前端保存,之后的每次请求,发送该JWT,验证用户,完成登录。
详细过程:
后端验证用户信息返回JWT:
url 请求的 view 是 rest_framework_jwt 中的 ObtainJSONWebToken,该 view 继承自 JSONWebTokenAPIView,使用的序列化类是 JSONWebTokenSerializer,该序列化类中通过重写 validate() 方法,进行用户的身份校验,验证通过后进行 token 的构建,并和用户名组合为字典存放到 _validated_data 参数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 源码位于 rest_framework_jwt/serializers.py
# JSONWebTokenSerializer中的validate()方法
def validate(self, attrs):
credentials = {
self.username_field: attrs.get(self.username_field),
'password': attrs.get('password')
}

if all(credentials.values()):
# 调用django的用户认证方法进行验证
user = authenticate(**credentials)

if user:
if not user.is_active:
msg = _('User account is disabled.')
raise serializers.ValidationError(msg)

# 用户认证通过后进行token的构造,以下两个方法也是token构造的主要方法
payload = jwt_payload_handler(user)
return {
# 将token和用户对象存储到一起,通过validate()最终返回给 _validated_data
'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)

JSONWebTokenAPIView 中获取到生成的 token,通过可选的方式(通过判断 api_settings.JWT_AUTH_COOKIE 的值决定是否通过 cookie 的方式进行传递 token 值,默认是 None,也就是不通过 cookie 进行传递值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 源码位于 rest_framework_jwt/views.py
class JSONWebTokenAPIView(APIView):
...
def post(self, request, *args, **kwargs):
# 调用 JSONWebTokenSerializer 进行数据的序列化
serializer = self.get_serializer(data=request.data) # request为drf重写,内部通过data属性保存传递过来的值

if serializer.is_valid(): # 进行验证(validata中进行了用户验证),验证通过之后,返回生成的token值和user属性
# serializer.object属性为rest_framework_jwt设置的属性方法,之际返回的是self.validated_data,也就是_validated_data中的值
user = serializer.object.get('user') or request.user
token = serializer.object.get('token')
# jwt_response_payload_handler方法用于自定义响应的内容,默认的只是返回{token:'token值'}
response_data = jwt_response_payload_handler(token, user, request)
response = Response(response_data)
# 判断JWT_AUTH_COOKIE是否设置,默认为None,表示不使用cookie的方式,设置的时候为cookie中存储的名称
if api_settings.JWT_AUTH_COOKIE:
# 设置过期时间,需要使用到api_settings.JWT_EXPIRATION_DELTA的配置,默认为300s
expiration = (datetime.utcnow() +
api_settings.JWT_EXPIRATION_DELTA)
# 设置cookie, 使用WT_AUTH_COOKIE设置的名称,token,过期时间
response.set_cookie(api_settings.JWT_AUTH_COOKIE,
token,
expires=expiration,
httponly=True) # 如果cookie中设置了HttpOnly属性,那么通过js脚本将无法读取到cookie信息,这样能有效的防止XSS攻击,窃取cookie内容,这样就增加了cookie的安全性
return response

return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

vue前端获得返回的JWT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 登录
export const login = params => {
return axios.post(`${host}/login/`, params)
}
// 登录调用的方法
login({
// vue传递 用户名和密码
username:this.userName,
password:this.parseWord
}).then((response)=> {
console.log(response);
// 验证通过之后,本地存储返回的信息
// 当前示例的返回的信息中由于使用的是djangorestframework_jwt默认的jwt_response_payload_handler(token, user, request),该方法中只返回了token,所以name不通过后端获得,由前端获取
cookie.setCookie('name',this.userName,7);
// 设置token,同时设置过期时间,这是因为djangorestframework_jwt使用非cookie形式传递token时,没有传递过期时间值,此时需要设置cookie的过期时间,
// 但是要注意,传递的token中在编码的时候,是有过期时间这个属性的,用于后端在接收到该token进行过期验证的,和这里的cookie的过期时间无关,token的构造参见 jwt_payload_handler(user) 的实现,由该方法定义。
// 此处定义的cookie的过期时间是用在前端浏览器中设置cookie保存的时间,JWT也有单独设置的过期时间用于保证JWT的时效性,这是在生成token的时候就定义并存储好的,
// 另外,此处cookie过期时间最好和JWT设置的过期时间保持一直,应小于等于JWT的值。
cookie.setCookie('token',response.data.token,7)
// 存储在store,更新store数据
that.$store.dispatch('setInfo');
// 获得返回值,说明用户验证通过,此时跳转到首页
this.$router.push({ name: 'index'})
})
.catch( function (error) {
// 异常处理
...
});

本例中使用的是非cookie传递的方式,后端直接将JWT通过Response进行返回,然后在Vue中通过response.data读取属性的方式获取到JWT并存储在cookie中,这里的cookie只起到了数据存储的作用,vue中之后会使用 cookie.getCookie(‘token’) 的方式读取数据使用。

vue 传递 token 值给后端,用于身份验证:
根据 djangorestframework_jwt 文档要求,需要以 Authorization: JWT <your_token> 的形式进行传递,所以在前端设置 request拦截器,为 request 请求添加该信息

1
2
3
4
5
6
7
8
9
10
11
// vue设置http request拦截器
axios.interceptors.request.use(
config => {
if (store.state.userInfo.token) { // 判断是否存在token,如果存在的话,则每个http header设置Authorization,添加token值
config.headers.Authorization = `JWT ${store.state.userInfo.token}`;
}
return config;
},
err => {
return Promise.reject(err);
});

通过这种方式向后端传递JWT,
后端通过设置 authentication_classes = (JSONWebTokenAuthentication,) 进行JWT认证
JSONWebTokenAuthentication 继承自 BaseJSONWebTokenAuthentication,该父类中提供了 authenticate() 方法用于验证用户,内部通过 get_jwt_value(request) 方法获得前端传递过来的token,内部通过调用 jwt_decode_handler(jwt_value) 反解 token 获得其中的 payload(该操作与之前的 jwt_encode_handler(payload) 相对),再通过 authenticate_credentials(payload) 获得user。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 位于 rest_framework/authentication.py
# JSONWebTokenAuthentication 中的 get_jwt_value 调用该方法获得请求头中的 Authorization 属性
def get_authorization_header(request):
auth = request.META.get('HTTP_AUTHORIZATION', b'') # 获得 Authorization 中的值
if isinstance(auth, str):
# Work around django test client oddness
auth = auth.encode(HTTP_HEADER_ENCODING)
return auth

# 位于 rest_framework_jwt/authentication.py
# JSONWebTokenAuthentication 中的 get_jwt_value
def get_jwt_value(self, request):
auth = get_authorization_header(request).split() # 通过split得到列表 ['JWT', 'token值']
auth_header_prefix = api_settings.JWT_AUTH_HEADER_PREFIX.lower()

if not auth: # 如果auth中没有值,则可能是通过cookie传递,查看 JWT_AUTH_COOKIE 是否设置
if api_settings.JWT_AUTH_COOKIE:
# 如果通过验证,则说明在之前是通过cookie的方式将JWT传递给前端的,那么这里也就一定会通过cookie返回了token,则之后的请求是一定会带上该cookie的,此时就可以直接获取并返回,不需要再进行下面的验证了。
return request.COOKIES.get(api_settings.JWT_AUTH_COOKIE)
return None

# 验证token 的前缀,这里使用的默认的 JWT
if smart_text(auth[0].lower()) != auth_header_prefix:
return None
# auth中获得值是 ['JWT', 'token值'] 形式,长度一定是为2的
if len(auth) == 1:
msg = _('Invalid Authorization header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid Authorization header. Credentials string '
'should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)
return auth[1] # 返回token值

# JSONWebTokenAuthentication 从父类 BaseJSONWebTokenAuthentication 中继承过来的验证方法 autheiticate(self, request)
def authenticate(self, request):
jwt_value = self.get_jwt_value(request) # 获得前端传递过来的token值
if jwt_value is None:
return None

try:
# 调用jwt_decode_handler(jwt_value)反解token获得其中的payload
payload = jwt_decode_handler(jwt_value)
except jwt.ExpiredSignature:
msg = _('Signature has expired.')
raise exceptions.AuthenticationFailed(msg)
except jwt.DecodeError:
msg = _('Error decoding signature.')
raise exceptions.AuthenticationFailed(msg)
except jwt.InvalidTokenError:
raise exceptions.AuthenticationFailed()

# 使用反解得到的payload获得存储的用户
user = self.authenticate_credentials(payload)

return (user, jwt_value)

可以定制的方法

jwt_response_payload_handler(token, user, request)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def jwt_response_payload_handler(token, user=None, request=None):
"""
可以自定义响应的内容,用于返回,可以定制要返回的数据,用于前端的获取
默认只返回了token值
Example:

def jwt_response_payload_handler(token, user=None, request=None):
return {
'token': token,
'user': UserSerializer(user, context={'request': request}).data
}

"""
return {
'token': token
}

可以通过在settings.py中配置,用于定制返回的内容,默认只返回了token值。

1
2
3
JWT_AUTH = {
'JWT_RESPONSE_PAYLOAD_HANDLER': '指定处理函数',
}

djangorestframework_jwt 配置

提供配置文件的方式,方便定制JWT的某些功能,除了上面的指定方法外,常用的配置还有以下几个:

1
2
3
4
5
6
import datetime

JWT_AUTH = {
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=7), # 设置JWT的过期时间
'JWT_AUTH_HEADER_PREFIX': 'JWT', # 指定接受token的前缀,常用的有Token和JWT两种
}

默认的 JWT_AUTH_HEADER_PREFIX 就是设置的 JWT 前缀,该设置在后端传递给前端token时并不其作用,而是在前端传递给后端的时候用到,用于在后端读取前端设置在 headers.Authorization 中的 Authorization: JWT 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b 时,验证前缀是否为settings.py中设置的前缀,所以该参数的设置只在后台验证的时候才会进行。

token功能的项目代码内嵌

有这样一种功能需求,我们在系统的注册功能中,当我们注册之后应该直接完成登录,显示登录状态,可以发现这部分的前端处理逻辑和登录功能的是类似,在跳转页面的时候也需要向后端传递token以完成身份的验证,但是djangorestframework_jwt只提供了login的url,并没有注册的,此时就需要我们手动在注册完成之后,手动构造符合djangorestframework_jwt要求的token并返回。
通过以上代码的分析,token的构造主要依赖两个方法,jwt_payload_handler(user)jwt_encode_handler(payload), 所以我们可以重写注册过程中需要使用到的 mixins.CreateModelMixin 中的create()方法,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def create(self, request, *args, **kwargs):
"""
为了完成配合前端实现的 注册完成后即自动登录的要求
重载create方法,完成token的返回
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)

# 重载perform_create方法,使其返回创建的实例对象,这里获取到该返回值
user = self.perform_create(serializer)
re_dict = serializer.data

# 分析jwt源码,调用源码组件,完成token的构建
payload = jwt_payload_handler(user)
# 获取返回的对象serializer.data,并添加token字段,由前端获取到
re_dict["token"] = jwt_encode_handler(payload)
re_dict["name"] = user.name if user.name else user.username
headers = self.get_success_headers(serializer.data)

# 返回修改后的值
return Response(re_dict, status=status.HTTP_201_CREATED, headers=headers)

登录状态和身份验证的关系

注意这里要区分 登录状态 和 身份验证的关系:
身份验证是前端传递相关数据,如用户信息,或者通过session、token等认证机制判断当前用户是谁,以及获得用户的信息。
登录状态是前端的表现,如用户通过身份认证后会得到相应的信息作为标记,前端通过判断这个标记,显示是否为登录状态。

session和token的区别

  • token 和 session 其实都是为了身份验证,session 一般翻译为会话,而 token 更多的时候是翻译为令牌。
  • session 服务器会保存一份,可能保存到缓存,文件,数据库,token 通过算法生成以及验证,后端不进行存储。
  • 其实 token 与 session 的问题是一种时间与空间的博弈问题,session 是空间换时间,而 token 是时间换空间。两者的选择要看具体情况而定。虽然确实都是“客户端记录,每次访问携带”,但 token 很容易设计为自包含的,也就是说,后端不需要记录什么东西,每次一个无状态请求,每次解密验证,每次当场得出合法/非法的结论。这一切判断依据,除了固化在 CS 两端的一些逻辑之外,整个信息是自包含的。这才是真正的无状态。而 sessionid ,一般都是一段随机字符串,需要到后端去检索 id 的有效性。万一服务器重启导致内存里的 session 没了呢?万一 redis 服务器挂了呢?