vue中jwt的实现

4,102 阅读8分钟

什么是jwt

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

传统的session认证

http协议本身是一种无状态的协议,这就意味着如果用户向我们的应用提供了用户名和密码来进行认证。那么下一次请求时,用户还要再一次进行用户认证才行,因为根据http协议,我木讷并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发送的请求,我们只能在服务器存储一份用户登陆的信息,这份登陆的信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们就识别请求来自于哪个用户了,这就是传统的给予session的认证。

但是这种给予session的认证使得应用很难得到扩展,随着不同客户端的用户的增加,独立的服务器已经无法承载更多的用户,而这时候基于session认证应用的问题就会暴露出来。

基于session认证所暴露的问题

每个用户经过我们的应用认证以后,我们的应用都要在服务端做一次记录,以方便用户下次请求时候的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

用户认证之后,服务端做认证记录,如果认证记录被保存在内存中的话,这意味着用户下次请求还必须请求在这台服务器上,这样才能拿到授权的资源,这样如果是在分布式的应用上,相应的就限制了负载均衡的能力,也就意味着限制了应用的扩展能力。

基于token的鉴权机制

基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着token认证机制下的应用不需要去考虑用户是在哪一台服务器登陆的,这就为应用的拓展提供了便利。

流程一般是这样的:

  • 用户使用用户名密码去请求服务器
  • 服务器进行验证用户的信息
  • 服务器通过验证发送给用户一个token
  • 客户端存储token,并且在每次请求的时候附送上这个token
  • 服务端验证token,并返回数据

这个token必须要在每次请求时传递给服务器,它应该被保存在请求头中。

JWT实践

1. 搭建vue脚手架,增加样例页

首先,我们创建一个vue脚手架,钩上路由,vuex的配置。然后增加三个样例页面:home,login,detail。

同时为三个页面配置相关的路由。

2. 搭建服务端

我们现在开始搭建一个服务端,使用express,首先,我们服务端需要允许跨域,由于我们还需要解析客户端传过来的token,所以我们可以使用body-parser插件去解析前端传过来的参数。并且,我们写一个示范的接口,来测试是否代码正确,服务端代码如下:

const express = require('express'); 
const jwt = require('jsonwebtoken'); 
const bodyParser = require('body-parser'); 
const app = express(); 
app.use((req, res, next) => { 
    res.header('Access-Control-Allow-Origin', '*'); 
    res.header('Access-Control-Allow-Headers', '*'); 
    res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');
    if(req.method.toLowerCase === 'options') { 
        return res.end(); 
    } 
    next();
}); 
app.use(bodyParser.json()); 
app.get('/get/username', (req, res) => { 
    res.json({ name: 'zhaozilong ' }) 
})

3. 封装axios

我们需要请求后端接口,那么我们就需要封装一下axios,我们建立一个request.js,代码如下:

import axios from 'axios'; 
class HttpRequest{ 
    constructor() { 
        this.baseUrl = process.env.NODE_ENV === 'production' ? '/' : 'http://localhost:3003'; 
        this.timeout = 3000; 
    } 
    merge(options) { 
        return {...options, baseUrl: this.baseUrl, timeout: this.timeout} 
    }
    request(options) { 
        const instance = axios.create({}); 
        const params = this.merge(options); 
        return instance(params) 
    } 
} 
export default new HttpRequest;

4. 增加API层

现在,我们需要增加API层,然后调取后端的接口

import axios from '../lib/request.js'; 
export const getUsername = () => { 
    return axios.request({ 
        url: '/get/username', 
        method: 'get' 
    })
}

这个时候,我们写个demo,调取这个接口,发现可以调取成功了。

5. 优化request.js

现在,我们还需要继续优化request.js,一个是我们需要在发送请求的时候展示loading,还有一个是我们需要增加请求头。优化后的代码如下:

import axios from 'axios'; 
import store from '../store/index.js'; 
class HttpRequest{ 
    constructor() { 
        this.baseUrl = process.env.NODE_ENV === 'production' ? '/' : 'http://localhost:3003'; 
        this.timeout = 3000; 
        this.loadingAPI = {}; 
    } 
    merge(options) { 
        return {
            ...options, 
            baseUrl: this.baseUrl, 
            timeout: this.timeout
        }
    } 
    setInterceptor(instance, url) { 
        instance.interceptors.request.use(config => {
            if (Object.keys(this.loadingAPI).length === 0) { 
                store.commit('SHOW_LOADING') 
            }; 
            config.headers.Authorization = 'zhaozilong'; 
            this.loadingAPI[url] = url; 
            return config; 
        }) 
        instance.interceptors.response.use(res => { 
            delete this.loadingAPI[url]; 
            if (Object.keys[this.loadingAPI].length === 0) { 
                store.commit('HIDE_LOADING');
            }; 
            return res.data; 
        }) 
    } 
    request(options) { 
        const instance = axios.create({}); 
        const params = this.merge(options); 
        this.setInterceptor(instance);
        return instance(params) 
    } 
} 
export default new HttpRequest;

6. 修改vuex

我们在request.js中增加了loading,然后触发了vuex中的mutations中的方法。现在,我们在vuex中增加该方法。

import Vue from 'vue'; 
import Vuex from 'vuex'; 
Vue.use(Vuex); 
export default new Vuex.store({ 
    state: { 
        isShowLoading: false
    }, 
    mutations: { 
        SHOW_LOADING(state) { 
            state.isShowLoading = true
        }, 
        HIDE_LOADING(state) { 
            state.isShowLoading = false; 
        } 
    } 
})

7. 引入iview

现在,我们需要在App.vue中根绝vuex的isShowLoading来切换loading的显示和隐藏,所以我们引入一个UI插件iview。我们下载好以后在main.js中引入即可:

import Vue from 'vue'; 
import App from './App.vue'; 
import router from './router'; 
import store from './store'; 
import iView from 'iview'; 
import 'iview/dist/styles/iview.css'; 
Vue.use(iView); 
Vue.config.productionTip = false; 
new Vue({ 
    store, 
    router, 
    render: h => h(App)
}).$mount('#app');

8. App.vue中引入loading

现在,我们需要在App.vue中引入loading。修改App.vue代码如下:

<template> 
    <div id="app">
        <div id="nav">
            <router-link to="/home">home</router-link>
            <router-link to="/login">login</router-link>
            <router-link to="/detail">detail</router-link>
        </div> 
        <Spin size="large" fix v-show="$store.state.isShowLoading">加载中</Spin> 
        <router-view /> 
    </div> 
</template>

9. 后端实现登陆功能

现在,前端的api层已经封装好了,并且已经可以在请求的时候增加loading了,在请求结束的时候loading消失。所以,现在就可以开始实现登陆的功能了,首先,我们在service服务端实现登陆功能。

我们的逻辑是:当用户登陆的时候,用户名是zhaozilong,那么我们就判断他登陆成功,并且返回成功状态码,用户名,token,以及登陆成功的提示语。如果用户名不是zhaozilong,那么我们就返回失败状态码以及登陆失败的提示语。

const express = require('express'); 
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser'); 
const app = express(); 
const scrent = 'pingan'; 
app.use((req, res, next) => { 
    res.header('Access-Control-Allow-Origin', '*'); 
    res.header('Access-Control-Allow-Headers', '*'); 
    res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');
    if(req.method.toLowerCase === 'options') {
        return res.end();
    } 
    next(); 
}); 
app.use(bodyParser.json()); 
app.get('/get/username', (req, res) => { 
    res.json({ name: 'zhaozilong ' })
}); 
app.post('/login', (req, res) => { 
    let {username} = req.body; 
    if (username === 'zhaozilong') { 
        res.json({ 
            code: 1, 
            username: 'zhaozilong', 
            token: jwt.sign({username: 'zhaozilong'}, scrent, { expiresIn: 20 }),
            message: '登陆成功' 
        }) 
    } else { 
        res.json({ code: 0, message: '账号不存在!' }) 
    } 
})
app.listen(3003);

10. 在Vuex中实现登陆功能

我们需要在登陆页去实现登陆,但是我们的登陆逻辑可以写在vuex中,这样的话其他页面需要实现登陆逻辑,就可以直接调用vuex中的actions中的方法。

实现逻辑是:首先拿到客户端输入的用户名,然后请求后段接口,如果登陆成功,则在localStorage中存储token,然后在vuex中存储用户名,然后返回成功,否则返回失败。

那么,我们先封装一个local.js,用于存储数据到localStorage中,代码如下:

export const setLocal = (key, value) => { 
    if (typeof value === 'object') { 
        value = JSON.stringify(value); 
    }; 
    localStorage.setItem(key, value) 
}; 
export const getLocal = key => localStorage.getItem(key);

现在,我们可以修改vuex,在里面增加登陆的功能,代码如下:

import Vue from 'vue'; 
import Vuex from 'vuex'; 
Vue.use(Vuex); 
import {getUserInfo} from '../api/getService.js';
import {setLocal} from '../lib/local.js'; 
export default new Vuex.store({ 
    state: { 
        isShowLoading: false, 
        username: '' 
    }, 
    mutations: { 
        SHOW_LOADING(state) { 
            state.isShowLoading = true 
        }, 
        HIDE_LOADING(state) { 
            state.isShowLoading = false; 
        }, 
        SET_USERNAME(state, value) { 
            state.username = value; 
        }
    }, 
    actions: { 
        async login({commit}, username) { 
            const response = await getUserInfo({username}); 
            if (response.code === 1) { 
                commit("SET_USERNAME", username); 
                setLocal('token', response.token); 
                return Promise.resolve(response.username)
            } else { 
                return Promise.reject(response.message) 
            } 
        } 
    } 
})

到现在,我们已经在vuex中实现了登陆的功能,所以现在需要去login.vue文件中引入这个功能。

11. login.vue中实现登陆

改造后的login.vue中的代码就比较简单了,如下:

<template> 
    <div class="login"> 
        <Input style="width:200px" v-model="username"> 
        <Button type="primary" @click="login">登陆</Button> 
    </div> 
</template> 
<script> 
    import {mapActions} from 'vuex'; 
    export default { 
        name: 'login', 
        data() { 
            return { 
                username: ''
            } 
        },
        methods: { 
            ...mapActions(['toLogin']), 
            login() { 
                this.toLogin(this.username).then(username => { 
                    this.$router.push('/home'); 
                }, error => { 
                    this.$Message.error(error) 
                }) 
            } 
        } 
    } 
</script>

12. 服务端增加鉴权接口

现在登陆已经做好了,那么我们就需要做鉴权了,我们先将服务端写好:

const express = require('express'); 
const jwt = require('jsonwebtoken'); 
const bodyParser = require('body-parser'); 
const app = express(); 
const scrent = 'pingan'; 
app.use((req, res, next) => { 
    res.header('Access-Control-Allow-Origin', '*'); 
    res.header('Access-Control-Allow-Headers', '*'); 
    res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');
    if(req.method.toLowerCase === 'options') { 
        return res.end(); 
    } 
    next();
}); 
app.use(bodyParser.json()); 
app.get('/get/username', (req, res) => { 
    res.json({ name: 'zhaozilong ' }) 
}); 
app.post('/login', (req, res) => { 
    let {username} = req.body; 
    if (username === 'zhaozilong') { 
        res.json({ 
            code: 1, 
            username: 'zhaozilong', 
            token: jwt.sign({username: 'zhaozilong'}, scrent, { expiresIn: 20 }), 
            message: '登陆成功' 
        }) 
    } else { 
        res.json({ code: 0, message: '账号不存在!' }) 
    } 
}) 
app.get('/validator/login', (req, res) => { 
    let token = req.headers.authorization; 
    jwt.verify(token, scrent, (error, decoded) => { 
        if (decoded) { 
            res.json({ 
                code: 1, 
                username: decoded.username,
                token: jwt.sign({username: decoded.username}, scrent, { expiresIn: 20 }),
                message: '权限校验成功!' 
            })
        } else { 
            res.json({ code: 0, message: '权限校验失败!' }) 
        } 
    })
}) 
app.listen(3003);

13. 在Vuex中实现权限校验

现在,我们需要去vuex中实现客户端的权限,代码如下:

import Vue from 'vue'; 
import Vuex from 'vuex'; Vue.use(Vuex); 
import {getUserInfo, getValidator} from '../api/getService.js'; 
import {setLocal} from '../lib/local.js'; 
export default new Vuex.store({ 
    state: { 
        isShowLoading: false, 
        username: ''
    }, 
    mutations: { 
        SHOW_LOADING(state) { 
            state.isShowLoading = true 
        }, 
        HIDE_LOADING(state) { 
            state.isShowLoading = false;
        }, 
        SET_USERNAME(state, value) { 
            state.username = value; 
        } 
    }, 
    actions: { 
        async login({commit}, username) { 
            const response = await getUserInfo({username}); 
            if (response.code === 1) { 
                commit("SET_USERNAME", username); 
                setLocal('token', response.token); 
                return Promise.resolve(response.username) 
            } else { 
                return Promise.reject(response.message) 
            } 
        }, 
        async validatorLogin({commit}) { 
            const response = await getValidator(); 
            if (response.code === 1) { 
                commit('SET_USERNAME', response.username); 
                setLocal('token', response.token); 
            }; 
            return response === 1; 
        } 
    } 
})

14. 在路由导航中权限校验

现在,我们需要在每次路由跳转的时候进行权限校验,我们的逻辑是:

  1. 如果需要权限,并且有权限,那么直接跳转
  2. 如果需要权限,但是没有权限,那么跳转登陆
  3. 如果不需要权限,但是有权限,如果是登陆页,跳转主页,否则直接跳转
  4. 如果不需要权限,也没有权限,那么直接跳转

代码如下:

import Vue from 'vue'; 
import App from './App.vue'; 
import router from './router'; 
import store from './store'; 
import iView from 'iview'; 
import 'iview/dist/styles/iview.css'; 
Vue.use(iView); 
Vue.config.productionTip = false; 
router.beforeEach(async (to, from, next) => { 
    const isLogin = await store.dispatch('validatorLogin'); 
    const needLogin = to.matched.some(match => match.meta.needLogin);
    if (needLogin) { 
        if (isLogin) { 
            next()
        } else { 
            next('/login') 
        }
    } else { 
        if (isLogin && to.name === 'login') { 
            next('/home') 
        } else { 
            next() 
        }
    } 
}) 
new Vue({ 
    store, 
    router, 
    render: h => h(App) 
}).$mount('#app');