koa + axios + jwt 实现token自动续期

  1. Json web token (JWT)
    1. header
    2. payload
    3. signature
    4. JWT的签发与验签
  2. koa-jwt实现token续期
  3. axios实现自动续期

深入理解token这篇文章详细讲解了token的作用及优势,这篇文章我们通过jwt和koa来简单实现一个自动续期token。

首先来看看JWT

Json web token (JWT)

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519),该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

一个完整的JWT长这样

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImJhaWh1emkiLCJhZG1pbiI6dHJ1ZSwiYWdlIjoxOCwiaWF0IjoxNjI3NTU4MjU3LCJleHAiOjE2Mjc1NTg4NTd9.FmiN7MoQm8uzGAFXXNve6z3r94JRVFGmCcKWVQyz2T9o-0ovhFRC1eQ6xgFX8AKFMSqMIBs8Yd_rDSXNFqBgQkxkBQEHqQlv4-BjhvxA3W1FpclUtqQfSQx9mWeWDN79RPOV7ayTZ-8e9hDJVqB3hoFZsWddHxens1IKbSEQylZrgEGNJVGbeN2sTNyHvXQoxUzft5E3k2odp5lnS-Fl0dwkrwsBEvQ81cNQio24d4VINBDCcF6nOBii6mEMA9VLBj4j1PDT2BEBgZlCbrvHbsuYCQUljvsHInkRrnySzc4DwiIxzrKEapr8raD--MHH0Iz78__hJrqCZTAxve-iaA

包括三段信息,通过.分隔,第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签名(signature).

jwt的头部承载两部分信息:

  • 声明类型,这里是JWT
  • 声明加密的算法 通常直接使用 HMACSHA256
    完整的头部就像下面这样的JSON:
{
  "alg":"RS256",      // 算法
  "typ":"JWT"         // 类型
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.

new Buffer.from('{"alg":"RS256","typ":"JWT"}').toString('base64');

// eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9

payload

载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分

  • 标准中注册的声明
  • 公共的声明
  • 私有的声明

标准中注册的声明 (建议但不强制使用):

  • iss: jwt签发者
  • sub: jwt所面向的用户
  • aud: 接收jwt的一方
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间
  • nbf: 定义在什么时间之前,该jwt都是不可用的.
  • iat: jwt的签发时间
  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

公共的声明:
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.

私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

定义一个payload:

{
  "username":"daryl",
  "age":18,
  "admin":true
}
new Buffer.from('eyJ1c2VybmFtZSI6ImJhaWh1emkiLCJhZG1pbiI6dHJ1ZSwiYWdlIjoxOCwiaWF0IjoxNjI3NTU4MjU3LCJleHAiOjE2Mjc1NTg4NTd9' , 'base64').toString();

// {"username":"baihuzi","admin":true,"age":18,"iat":1627558257,"exp":1627558857}

signature

签名是把headerpayload对应的json结构进行base64url编码之后得到的两个串用英文句点号拼接起来,然后根据header里面alg指定的签名算法生成出来的。

算法不同,签名结果不同。secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

JWT的签发与验签

RS256是非对称算法,有一对公私秘钥,需要自己生成,通过私钥进行签名,只有自己签发的才是合法的,防止恶意伪造token,通过公钥进行验签,如果token被修改挥着过期了,验签就会失败

let jwt = require('jsonwebtoken');
let fs = require('fs')

let data = {
    username: 'daryl',
    age: 18,
    admin: true
}


// 用私钥进行签名,用公钥进行验签,防止客户端伪造token
let privateKey = fs.readFileSync('./private.pem')
let token = jwt.sign(data, privateKey, { algorithm: 'RS256', expiresIn: 60 * 10 });
console.log(token)

// 用公钥进行验签
let publicKey = fs.readFileSync('./public.pem')
let res = jwt.verify(token, publicKey, { algorithm: 'RS256' })
console.log(res);
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImRhcnlsIiwiYWdlIjoxOCwiYWRtaW4iOnRydWUsImlhdCI6MTYyNzU3Mzc1MiwiZXhwIjoxNjI3NTc0MzUyfQ.snfD-YTXbCGb7et_BYtmw2FGZQ7wev_DAcdnn-StN13_PSpOw5zEUN7ldsL7_8RFH5m5Mu5Ot6WzdTKgP8JiuZuO-G01QsdECvSeEU3D7r2Yoj2x_H13lZpYpuZ6dgVsKE4CeWDME1MAN1YRwIygkse9Ds614nnfIx6ewRs1FNZXnusEXx4D6T1fMmhLBEcJs5gdFMdSrNQyuJvZtmIyqzY5wYYGfHen1CRmM_cFSq8EcnGgLsR_91S4sDzw3JVfw47rV8vp3OwlvI2Nldz_Zk7C6wLdkPw9VHxCigC4mJZ10tH4ICKU8XpZEzxhHgu7_o_luOOvXGfa6dm4-CrR6A


{
  username: 'daryl',
  age: 18,
  admin: true,
  iat: 1627573752,  // 签发时间
  exp: 1627574352   // 过期时间
}

koa-jwt实现token续期

const Koa = require('koa');
const Router = require('koa-router')
const betterBody = require('koa-better-body');
const jwt = require('jsonwebtoken')
const koaJwt = require('koa-jwt')

// 假用户数据
const user = { username: 'jerry', password: '123456' }

// jwt 秘钥,默认使用的是HS256算法,签名和验签使用同一个秘钥
const jwtSecret = 'sdD(Sdsdfsd^%8ds^^&5s'


let app = new Koa()
app.listen(8080);

// 跨域设置,需要注意的是,要将Authorization头设置到Access-Control-Allow-Headers里面去,否则无法跨域发送Authorization头
app.use(async (ctx, next) => {
    ctx.set('Access-Control-Allow-Origin', '*');
    ctx.set('Access-Control-Allow-Headers', 'x-requested-with,content-type,Authorization')
    ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
    if (ctx.method == 'OPTIONS') {
        ctx.set("Access-Control-Max-Age", "1728000000");
        ctx.body = 'ok';
    } else {
        await next();
    }
})

app.use(betterBody());
let router = new Router();

// 模拟登录接口,成功就发送token
// 发送accessToken 和 refreshToken两个token
router.post('/login', async ctx => {
    let { username, password } = ctx.request.fields;
    if (!username) {
        ctx.body = { code: 201, msg: '用户名不存在' };
    } else {
        let { username: name, password: pass } = user
        if (username == name && password == pass) {
            let token = createToken({ username })
            ctx.body = { code: 200, msg: '登录成功', token }
        } else {
            ctx.body = { code: 202, msg: '用户名或密码错误' }
        }
    }
})

// token自动续期接口
router.post('/refreshToken', async ctx => {
    let refreshToken = ctx.headers.authorization;
    await verifyToken(refreshToken)
        .then(async (token) => {
            // 校验成功就重新发送accessToken和refreshToken
            let newToken = createToken({ username: token.username });
            // 等待5秒再返回,方便查看效果
            await sleep(5000).then(() => {
                ctx.body = { code: 200, msg: '续期成功', token: newToken }
            })
        })
        .catch((e) => {
            ctx.status = 402;
            ctx.body = { code: 402, msg: '续期失败,请重新登录' }
        })
})


// ---------------------------------koa-jwt----------------------------------
// 验证失败时捕获401,返回自定义信息
app.use(function (ctx, next) {
    return next().catch((err) => {
        if (401 == err.status) {
            ctx.status = 401;
            ctx.body = { code: 401, msg: 'token expired' };
        } else {
            throw err;
        }
    });
});

// 默认校验在请求头中的[ Authorization: Bearer TOKEN ] 头,'Bearer ' (后面有一个空格)
// token不合法或者过期都会返回401错误
// 另外定义在unless中路由不会被校验
app.use(koaJwt({ secret: jwtSecret }).unless({ path: [/^\/login/, /^\/refreshToken/] }))

// ---------------------------------koa-jwt----------------------------------


// 测试接口
router.get('/a', async ctx => {
    ctx.body = { code: 200, msg: 'a' }
})

router.get('/b', async ctx => {
    ctx.body = { code: 200, msg: 'b' }
})

router.get('/c', async ctx => {
    ctx.body = { code: 200, msg: 'c' }
})

app.use(router.routes())

// 生成token函数
function createToken(obj) {
    // 为了模拟续期,把token的时效设置短一点,单位是秒
    // 生成两个token,accessToken用于接口访问,refreshToken用于请求token续期接口
    let accessToken = jwt.sign(obj, jwtSecret, { expiresIn: 15 })
    let refreshToken = jwt.sign(obj, jwtSecret, { expiresIn: 30 })
    return { accessToken, refreshToken }
}

// 校验refreshToken函数
function verifyToken(refreshToken) {
    return new Promise((resolve, reject) => {
        jwt.verify(refreshToken.split(' ')[1], jwtSecret, (err, token) => {
            if (err) {
                reject(err)
            } else {
                resolve(token)
            }
        })
    })
}

// 异步延时函数
function sleep(time = 0) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve();
        }, time);
    })
}

axios实现自动续期

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="./axios.min.js"></script>
</head>

<body>
    <button id="login">登录</button>
    <button id="abc">同时获取abc</button>
    <script>
        let loginBtn = document.querySelector('#login');
        let requestBtn = document.querySelector('#abc');

        axios.defaults.baseURL = 'http://localhost:8080'

        loginBtn.onclick = function () {
            axios.post('/login', {
                username: 'jerry',
                password: '123456'
            }).then(res => {
                if (res.data.code == 200) {
                    localStorage.setItem('accessToken', res.data.token.accessToken)
                    localStorage.setItem('refreshToken', res.data.token.refreshToken)
                } else {
                    alert('用户名或密码错误')
                }
            })
        }

        requestBtn.onclick = function () {
            axios.all([axios.get('/a'), axios.get('b'), axios.get('/c')]).then(axios.spread(function (a, b, c) {
                console.log(a.data, b.data, c.data);
            }))
        }

        // 请求拦截器,根据url设置不同的请求token
        axios.interceptors.request.use(function (config) {
            if (config.url == '/refreshToken') {
                config.headers.Authorization = 'Bearer ' + localStorage.getItem('refreshToken')
            } else if (config.url !== '/login') {
                config.headers.Authorization = 'Bearer ' + localStorage.getItem('accessToken')
            }
            return config;
        }, function (error) {
            return Promise.reject(error);
        });

        // 如果正在请求续期接口,则不再请求了,避免重复请求续期接口
        let isRefreshing = false;
        // 请求401后,把失败的请求放在这里面,等到token续期后拿出来重新请求
        let retryRequests = [];

        axios.interceptors.response.use(response => {
            const {code} = response.data;
            if (code !== 200) {
                return Promise.reject(response)
            } else {
                return response
            }
        }, error => {
            if (!error.response) return Promise.reject(error)
            // 401 accessToken过期,402 refreshToken过期
            if (error.response.data.code === 401) {
                const config = error.config
                if (!isRefreshing) {
                    isRefreshing = true;
                    axios.post('/refreshToken').then(res => {
                        if (res.data.code == 200) {
                            localStorage.setItem('accessToken', res.data.token.accessToken)
                            localStorage.setItem('refreshToken', res.data.token.refreshToken)
                            retryRequests.forEach(req => req())
                            retryRequests = []
                            return axios(config)
                        }
                    }).catch(err => {
                        alert('请重新登录');
                    }).finally(()=>{
                        isRefreshing = false;
                    })
                } else {
                    return new Promise((resolve) => {
                        // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
                        retryRequests.push((newToken) => {
                            resolve(axios(config))
                        })
                    })
                }
            }
        });
    </script>
</body>
</html>

查看效果:

可以看到,多个请求401,只会去调用一次refreshToken接口,并且当续期接口成功返回后,之前失败的401请求也会重新请求,当续期接口的token也过期后,会返回402,让用户重新登录;


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 289211569@qq.com