什么是JWT(what)
- JWT(JSON Web Token)是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,以JSON对象的形式在各方之间安全地传输信息。
- JWT是一个数字签名,生成的信息是可以验证并被信任的。
- 使用密钥(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对JWT进行签名。
- JWT是目前最流行的跨域认证解决方案
JWT令牌结构
SON Web令牌以紧凑的形式由三部分组成,这些部分由点(.)分隔,分别是:
- Header
- Payload
- Signature
即为: xxxx.yyyy.zzzz
Header
Header通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法(例如HMAC SHA256或RSA)。 例如:
{
"alg": "HS256",
"typ": "JWT"
}
Header会被Base64Url编码为JWT的第一部分。即为:
$ echo -n '{"alg":"HS256","typ":"JWT"}'|base64
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload
Payload是有关实体(通常是用户)和其他数据的声明,它包含三部分:
注册声明
这些是一组预定义的权利要求,不是强制性的,而是建议使用的,以提供一组有用的可互操作的权利要求。其中一些是: iss(JWT的签发者), exp(expires,到期时间), sub(主题), aud(JWT接收者),iat(issued at,签发时间)等。
注意:声明名称都是三个字符
公开声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密。
私有声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
例子:
{ "iat": 1593955943,
"exp": 1593955973,
"uid": 10,
"username": "test",
"scopes": [ "admin", "user" ]
}
Payload会被Base64Url编码为JWT的第二部分。即为:
$ echo -n '{"iat":1593955943,"exp":1593955973,"uid":10,"username":"test","scopes":["admin","user"]}'|base64
eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE1OTM5NTU5NDMsInVpZCI6MTAsImV4cCI6MTU5Mzk1NTk3Mywic2NvcGVzIjpbImFkbWluIiwidXNlciJdfQ
注意:对于已签名的令牌,此信息尽管可以防止篡改,但任何人都可以读取。除非将其加密,否则请勿将机密信息放入JWT的有效负载或报头元素中。
Signature
Signature部分的生成需要base64编码之后的Header,base64编码之后的Payload,密钥(secret),Header需要指定签字的算法。
例如,如果要使用HMAC SHA256算法,则将通过以下方式创建签名:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
整合在一起
输出是三个由点分隔的Base64-URL字符串,可以在HTML和HTTP环境中轻松传递这些字符串,与基于XML的标准(例如SAML)相比,它更紧凑。
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE1OTM5NTU5NDMsInVpZCI6MTAsImV4cCI6MTU5Mzk1NTk3Mywic2NvcGVzIjpbImFkbWluIiwidXNlciJdfQ.VHpxmxKVKpsn2Iytqc_6Z1U1NtiX3EgVki4PmA-J3Pg"
JWT是无状态授权机制,服务器的受保护路由将Header中检查有效的token,如果存在,则将允许用户访问受保护的资源。如果JWT包含必要的数据,则可以减少查询数据库中某些操作的需求。
什么时候使用JWT(when)
- 授权:一旦用户登录,每个后续请求将包括JWT,从而允许用户访问该令牌允许的路由,服务和资源。单一登录是当今广泛使用JWT的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。这也是JWT最常见的方案。
- 信息交换:JSON Web令牌是各方之间安全地传输信息的好办法。对JWT进行签名,所以您可以确保发件人是他们所说的人。由于,签名可以设置有效时长,可以验证内容是否遭到篡改。
如何使用JWT(how)
JWT工作流程
根据下面的这张流程图来分析一下JWT的工作过程
- 1 用户登录:提供用户名和密码;
- 2 JWT生成token和refresh_token,返回客户端;(注意:refresh_token的过期时间长于token的过期时间)
- 3 客户端保存token和refresh_token,并携带token,请求服务端资源;
- 4 服务端判断token是否过期,若没有过期,则解析token获取认证相关信息,认证通过后,将服务器资源返回给客户端;
- 5 服务端判断token是否过期,若token已过期,返回token过期提示;
- 6 客户端获取token过期提示后,用refresh_token接着继续上一次请求;
- 7 服务端判断refresh_token是否过期,若没有过期,则生成新的token和refresh_token,并返回给客户端,客户端丢弃旧的token,保存新的token;
- 8 服务端判断refresh_token是否过期,若refresh_token已过期,则返回给客户端token过期,需要重新登录的提示。
python+flask+JWT实战
import time
from functools import wraps
from flask import Flask, request, jsonify
import jwt
from jwt import ExpiredSignatureError
app = Flask(__name__)
max_time = 60
refresh_max_time = 120
token_secret = "This is a secret"
def verify_token(func):
@wraps(func)
def decorator(*args, **kwargs):
try:
token = request.headers["token"]
print(token)
data = jwt.decode(token, token_secret, algorithms=['HS256'])
now = int(time.time())
time_interval = now - data['time']
if time_interval >= max_time:
# create new token
token, refresh_token = creat_token()
return jsonify({"token": token, "refresh_token": refresh_token})
except ExpiredSignatureError:
return "Token expired"
except Exception as ex:
print(ex)
return "Log in again"
return func(*args, **kwargs)
return decorator
def creat_token(uid):
now = int(time.time())
payload = {'uid': uid, 'time': now, 'exp': now + max_time}
refresh_payload = {'uid': uid, 'time': now, 'exp': now + refresh_max_time}
token = jwt.encode(payload, token_secret, algorithm='HS256')
refresh_token = jwt.encode(refresh_payload, token_secret, algorithm='HS256')
return token, refresh_token
@app.route('/login', methods=["POST"])
def login():
user_name = request.values.get('user_name')
password = request.values.get('password')
# @TODO 根据user_name和password 获取唯一的uid
uid = 10
token, refresh_token = creat_token(uid=uid)
return jsonify({"token": token, "refresh_token": refresh_token})
@app.route('/test', methods=['GET'])
@verify_token
def test():
return 'hello world'
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
第三方库-itsdangerous
isdangerous简介
itsdangerous支持JSON Web 签名 (JWS),内部默认使用了HMAC和SHA1来签名,其中类JSONWebSignatureSerializer
内部与JWT一致,也分成三部分(header,payload,signature),查看源码可知:
def dumps(self, obj, salt=None, header_fields=None):
"""Like :meth:`.Serializer.dumps` but creates a JSON Web
Signature. It also allows for specifying additional fields to be
included in the JWS header.
"""
header = self.make_header(header_fields)
signer = self.make_signer(salt, self.algorithm)
return signer.sign(self.dump_payload(header, obj))
def dump_payload(self, header, obj):
base64d_header = base64_encode(
self.serializer.dumps(header, **self.serializer_kwargs)
)
base64d_payload = base64_encode(
self.serializer.dumps(obj, **self.serializer_kwargs)
)
return base64d_header + b"." + base64d_payload
- obj保存用户相关信息,类似JWT中的payload
- base64url对obj和header进行编码之后,使用
.
拼接 - 将拼接之后的数据,作为signer的输入以及初始化
__init__
中用户定义的secret来生成新的token
感兴趣的朋友可以直接参看github源码,这里不再展开赘述。
python+flask+isdangerous实战
import time
from functools import wraps
from flask import Flask, request, jsonify
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer, SignatureExpired
app = Flask(__name__)
max_time = 60
refresh_max_time = 120
token_secret = "This is a secret"
def verify_token(func):
@wraps(func)
def decorator(*args, **kwargs):
try:
token = request.headers["token"]
print(token)
s = Serializer(token_secret)
data = s.loads(token)
now = int(time.time())
time_interval = now - data['time']
if time_interval >= max_time:
# create new token
token, refresh_token = creat_token()
return jsonify({"token": token, "refresh_token": refresh_token})
except SignatureExpired:
return "Token expired"
except Exception as ex:
print(ex)
return "Log in again"
return func(*args, **kwargs)
return decorator
def creat_token(uid):
now = int(time.time())
s = Serializer(token_secret, expires_in=max_time)
token = s.dumps({"uid": uid, "time": now}).decode("ascii")
refresh_s = Serializer(token_secret, expires_in=refresh_max_time)
refresh_token = refresh_s.dumps({"uid": uid, "time": now}).decode("ascii")
return token, refresh_token
@app.route('/token', methods=["POST"])
def token():
user_name = request.values.get('user_name')
password = request.values.get('password')
# @TODO 根据user_name和password 获取唯一的uid
uid = 10
token, refresh_token = creat_token(uid=uid)
return jsonify({"token": token, "refresh_token": refresh_token})
@app.route('/test', methods=['GET'])
@verify_token
def test():
return 'hello world'
if __name__ == "__main__":
app.run(host="0.0.0.0")
TimedJSONWebSignatureSerializer
相比JSONWebSignatureSerializer
在header中赠加了过期时间,如果过期会抛出SignatureExpired
异常。
问题
用户登出,如何设置token无效?
JWT是无状态的,用户登出设置token无效就已经违背了JWT的设计原则,但是在实际应用场景中,这种功能是需要的,那该如何实现呢?提供几种思路:
- 用户登出,浏览器端丢弃token
- 使用redis数据库,用户登出,从redis中删除对应的token,请求访问时,需要从redis库中取出对应的token,若没有,则表明已经登出
为了保持数据的一致性,每一次认证都需要从redis中取出对应的token,每一次都以redis中的token为准。
使用redis,两个不同的设备,一个设备登出,另外一个设备如何处理?
请思考这样一种场景:
- 同一个用户从两个设备登陆到服务端(设备1,设备2);
- 设备1登出,删除redis中的对应的token
- 设备2再次请求数据时,redis中的数据为空,需要重新登录。
很明显,这种情况是不应该出现的,说一下自己的想法:
- 每一个设备与用户生成唯一的key,保存在redis中,即设备1的用户登出,只删除对应的token,设备2的token仍然存在
- 服务器端维护一个版本号,相同用户不同设备登入,版本号加1,这样保持key的唯一性(和上面差不多)