一文搞懂JWT用户认证全过程

1,325 阅读6分钟

什么是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的唯一性(和上面差不多)