🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
[TOC] >[success] # 登陆/登出以及JWT认证 在本章中将使用一个 **后端的真实服务** ,来结合这个服务做 **前端应用** 的 **登陆** 以及 **登出** ,并且我们使用 **JWT(全称:Json Web Token)** 来 **进行认证** 。 >[success] ## 后端代码概览 这里使用 **[express搭建](https://www.kancloud.cn/wangjiachong/code/1272384)** 了一个服务,这个 **服务运行在本地的 3000端口** ,大家想看可以直接进行 **代码下载** ,下载 **现成的代码** ,进行运行,下面主要看一下几个主要用到的 **后端接口** 。 **本地服务代码下载地址** : 链接:https://pan.baidu.com/s/1LcKTiDsoZy_RmEjmfJuKIw 提取码:cdaq **运行服务执行指令** : ~~~ npm start ~~~ 1. **serve/routes/index.js** **login登陆接口** ~~~ var express = require('express'); var router = express.Router(); const jwt = require('jsonwebtoken') // 模拟从数据库获取用户信息 const getPasswordByName = (name) => { return { password: '123' } } router.post('/getUserInfo', function(req, res, next) { res.status(200).send({ code: 200, data: { name: 'Lison' } }) }); // 登陆接口 router.post('/login', function(req, res, next) { // 获取前端传过来的userName跟password const { userName, password } = req.body // 如果有用户名 if (userName) { // 获取用户信息(如果有密码通过这个方法去数据库查询该用户信息,如果没有密码,用户信息就返回一个空字符串) const userInfo = password ? getPasswordByName(userName) : '' // 【用户信息为空】 或者 【密码为空】 或者 【输入密码与用户信息密码不对】3项中1项不对就抛出错误 if (!userInfo || !password || userInfo.password !== password) { res.status(401).send({ // 抛出错误:用户名或密码不对 code: 401, mes: 'user name or password is wrong', data: {} }) } else { // 成功 res.send({ // 给前端返回一个token,token是通过一个jwt的一个库来生成的 code: 200, mes: 'success', data: { // JWT生成规则: 可以自己来定义规则 // 参数1:是一个对象传入的是 【用户名称】 // 参数2:我们用来加密的一个自定义的一个字符串,这里可以定义我们的密钥,这里随便写一个abcd // 参数3:我们可以在里面设置一些信息,这里设置了一个【token过期时间】 token: jwt.sign({ name: userName }, 'abcd', { expiresIn: '1d' // 1d = 1天 10000 = 10秒 }) } }) } } else { // 如果无用户名 res.status(401).send({ // 抛出错误:用户名为空 code: 401, mes: 'user name is empty', data: {} }) } }); module.exports = router; ~~~ 2. **serve/routes/users.js** **授权接口** ~~~ var express = require('express'); var router = express.Router(); const jwt = require('jsonwebtoken') /* GET users listing. */ router.get('/', function(req, res, next) { res.send('respond with a resource'); }); router.get('/getUserInfo', function(req, res, next) { res.send('success') }) // 授权接口 router.get('/authorization', (req, res, next) => { // req.userName从app.js中中间件(拦截器)的token都正确才给添加的用户名称 const userName = req.userName res.send({ code: 200, mes: 'success', data: { token: jwt.sign({ name: userName }, 'abcd', { expiresIn: '1d' // 1d = 1天 10000 = 10秒 }) } }) }) module.exports = router; ~~~ 3. **serve/app.js(中间件:类似拦截器)** **应用核心配置文件** ~~~ var createError = require('http-errors'); var express = require('express'); var path = require('path'); var cookieParser = require('cookie-parser'); var logger = require('morgan'); const jwt = require('jsonwebtoken') var indexRouter = require('./routes/index'); var usersRouter = require('./routes/users'); var dataRouter = require('./routes/data'); var app = express(); // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'jade'); app.use(logger('dev')); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); // app.all('*', (req, res, next) => { // res.header('Access-Control-Allow-Origin', '*') // res.header('Access-Control-Allow-Headers', 'X-Requested-With,Content-Type') // res.header('Access-Control-Allow-Methods','PUT,POST,GET,DELETE,OPTIONS') // next() // }) // 白名单(不需要做token校验的接口) const whiteListUrl = { get: [ ], post: [ '/index/login', ] } // 判断请求是否在白名单中的方法 const hasOneOf = (str, arr) => { return arr.some(item => item.includes(str)) } // 中间件(类似拦截器,每次请求时候会走这里) app.all('*', (req, res, next) => { // 获取当前请求方式并且转换成小写(post 或者 get等等) let method = req.method.toLowerCase() // 获取当前请求的路径 let path = req.path // 有一些接口是不需要token校验的,所以在这里设置一下白名单, // 如果请求方式在白名单里面的对应上,并且请求方式里有请求的这个地址就返回true,接口正常执行,不需要token if(whiteListUrl[method] && hasOneOf(path, whiteListUrl[method])) next() // 白名单走这里 else { // 白名单之外的都需要token校验 // 在请求的header中取出authorization const token = req.headers.authorization // 判断没有token就抛出没有token请登录 if (!token) res.status(401).send('there is no token, please login') else { // 有token就判断使用JWT提供的方法,校验token是否正确 // 第一个参数把获取到的token传入 // 第二个参数是我们生成token时候传入的密钥,当然这个密钥可以抽离出一个文件每次从文件中获取密钥 // 第三个参数是一个回调函数,第一个参数是错误信息,第二个参数是从token中解码出来的信息 jwt.verify(token, 'abcd', (error, decode) => { if (error) res.send({ // token错误 code: 401, mes: 'token error', data: {} }) else { // token正确返回用户名,继续往下走 req.userName = decode.name next() } }) } } }) app.use('/index', indexRouter); app.use('/users', usersRouter); app.use('/data', dataRouter); // catch 404 and forward to error handler app.use(function(err, req, res, next) { next(createError(404)); }); // error handler app.use(function(err, req, res, next) { // set locals, only providing error in development res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; // render the error page res.status(err.status || 500); res.render('error'); }); module.exports = app; ~~~ >[success] ## 登陆以及Token处理 具体操作如下: >[success] ### 安装所需依赖 首先 **安装2个模块** ,**js-cookie(它是对cookie进行一些操作,可以设置、读取cookie)** 、**md5(可以对字符串进行md5加密,用在登陆时候提交密码时候加密密码)** ,然后我们依次 **执行安装指令** ,进行安装 **js-cookie 安装指令** ~~~ npm install js-cookie --save ~~~ **md5 安装指令** ~~~ npm install md5 ~~~ >[success] ### 登陆页面逻辑 ![](https://img.kancloud.cn/f3/49/f34999ee35fa12ddb6d7e3dc0a9a75f9_688x454.png) ![](https://img.kancloud.cn/da/ce/dacee54a1aac4c31c64f2272951bf296_2169x235.jpg) 1. **登陆页面代码以及逻辑** 在 **login.vue(登陆页面)** 里面写好一个 **简单的表单** ,大概是下图这个样子 ![](https://img.kancloud.cn/a7/40/a740bc7ecab76ad10a982e5fdebc8255_436x43.png) **点击登陆按钮** 后执行 **vuex** 中的 **actions** 的 **login异步方法** **src/views/login.vue** ~~~ <template> <div> <input type="text" placeholder="请输入账号" v-model="userName" /> <input type="password" placeholder="请输入密码" v-model="password" /> <button @click="handleSubmit">登陆</button> </div> </template> <script> import { mapActions } from 'vuex' export default { name: 'login_page', data(){ return { userName: 'Lison', // 用户名 password: '123' // 密码 } }, methods: { // 引入action中的login方法 ...mapActions([ 'login' ]), // 登陆 handleSubmit(){ this.login({ userName: this.userName, password: this.password }).then(() => { console.log('成功') this.$router.push({ name: 'home' }) }).catch(error => { console.log(error) }) } } } </script> <style> </style> ~~~ 2. **vuex中的代码展示** **点击登陆时候** 调用 **vuex** 中 **actions** 里定义的 **login方法**,这个 **login方法** 中 **调用了后端的登陆接口** **src/store/module/user.js** ~~~ // 接口引入 import { login, authorization } from '@/api/user' // 引入业务类方法 import { setToken } from '@/lib/util' const state = {} const mutations = {} const actions = { /** * 登陆方法 * @param commit 执行一个mutation方法来修改state * @param params this.$store.dispatch('login', '我是params')时传的参数 */ login({ commit }, { userName, password }){ return new Promise((resolve, reject) => { // 登陆接口 login({ userName, password }).then(res => { if(res.code === 200 && res.data.token){ setToken(res.data.token) resolve() } else { reject(new Error('错误')) } }).catch(error => { reject(error) }) }) }, /** * 校验token是否失效 * @param commit 执行一个mutation方法来修改state * @param token token信息 */ authorization({ commit }, token){ return new Promise((resolve, reject) => { authorization().then(res => { if(parseInt(res.code) === 401){ reject(new Error('token error')) } else { resolve() } }).catch(error => { reject(error) }) }) } } export default { state, mutations, actions } ~~~ 3. **接口文件** **src/api/user.js** ~~~ import axios from './index' // 登陆接口 export const login = ({ userName, password }) => { return axios.request({ url: '/index/login', method: 'post', data: { userName, password } }) } // 检验token是否有效 export const authorization = () => { return axios.request({ url:'/users/authorization', method: 'get' // 这行可以不写,默认是get }) } ~~~ **接口成功** 后, **后端会返回一个 token** ,我们需要把 **token** 储存起来,在 **后续接口调用时,将它添加到我们请求的header中,传给后端,后端拿到 token 进行验证**,此时需要用到刚刚下载的 **js-cookie** 在 **util.js** 中 **封装一个储存 token 的业务类方法** ,代码如下: **src/lib/util.js** ~~~ import Cookie from 'js-cookie' // 设置title export const setTitle = (title) => { window.document.title = title || 'admin' // 默认title } /** * 设置token * @param {string} token - 登陆成功后,返回的token * @param {string} tokenName - 储存到Cookie时的token名字 */ export const setToken = (token, tokenName = 'token') => { Cookie.set(tokenName, token) } /** * 获取token * @param {string} tokenName - 储存到Cookie时的token名字 */ export const getToken = (tokenName = 'token') => { return Cookie.get(tokenName) } ~~~ 4. **路由配置** **login接口成功返回token** 后 **需要跳转到首页** ,此时需要在 **路由拦截器中做判断处理** ,如果有 **token** 会调用一个接口,进行 **token是否可用的校验 ,如果校验成功跳转到首页** ,**如果没有 token 证明未登录,跳转到登录页** **src/router/index.js** ~~~ import Vue from 'vue' import Router from 'vue-router' import routes from './router' import store from '@/store' import { setTitle, setToken, getToken } from '@/lib/util' // 注册路由 Vue.use(Router) // vue-router实例 const router = new Router({ routes }) // 注册全局前置守卫 router.beforeEach((to, from, next) => { // 动态设置title to.meta && setTitle(to.meta.title) // 获取token const token = getToken() if(token){ // 已登录 // 调用接口判断token是否失效 store.dispatch('authorization', token).then(() => { // token验证成功 // 如果跳转的页面为登陆页,就强制跳转到首页 if(to.name === 'login') next({ name: 'home' }) else next() }).catch(error => { // token验证错误 // 这里需要清空token,再返回登录页,不然会陷入死循环,回到登录页还是有token,token失效还是回到登录页,如此反复 setToken('') // 这里也可以使用js-cookie提供的clear方法 next({ name: 'login' }) }) } else { // 未登录 // 如果去的页面是登陆页,直接跳到登陆页 if(to.name === 'login') next() // 如果不是登陆页,强行跳转到登陆页 else next({ name: 'login' }) } }) export default router ~~~ 5. **axios请求拦截器添加token** 因为在调用 **authorization 校验接口** 时,需要 **参数 token** , **只需要添加到请求拦截器** 中即可,这样后续接口的请求 **token都会在请求头上添加** 。 **src/lib/axios.js** ~~~ import axios from 'axios' import { baseURL } from '@/config' import { getToken } from '@/lib/util' class HttpRequest { constructor(baseUrl = baseURL){ // baseUrl = baseURL 是ES6的默认值写法等同于 baseUrl = baseUrl || baseURL this.baseUrl = baseUrl // this指向创建的实例,当你使用new HttpRequest创建实例时候,它会把this中定义的变量返回给你 this.queue = {} // 创建队列,每次请求都会向里面添加一个key:value,请求成功后就会去掉这个key:value,直到this.queue中没有属性值时,loading关闭 } /** * 默认options配置 */ getInsideConfig(){ const config = { baseURL: this.baseUrl, headers: { // } } return config } distroy (url) { delete this.queue[url] if (!Object.keys(this.queue).length) { // Spin.hide() } } /** * 拦截器 * @param {Object} instance - 通过axios创建的实例 * @param {String} url - 接口地址 */ interceptors(instance, url){ /** * 请求拦截器 * @param {Function} config - 请求前的控制 * @param {Function} error - 出现错误的时候会提供一个错误信息 */ instance.interceptors.request.use(config => { // 添加全局的Lodaing... if(!Object.keys(this.queue).length){ // Spin.show() } this.queue[url] = true // 每次请求都会把token加到请求头中 config.headers['Authorization'] = getToken() return config }, error => { return Promise.reject(error) }) /** * 响应拦截器 * @param {Function} res - 服务端返回的东西 * @param {Function} error - 出现错误的时候会提供一个错误信息 */ instance.interceptors.response.use(res => { this.distroy(url) // 关闭全局的Lodaing... const { data } = res return data }, error => { this.distroy(url) // 关闭全局的Lodaing... return Promise.reject(error.response.data) }) } request(options){ const instance = axios.create() options = Object.assign(this.getInsideConfig(), options) // Object.assign会将2个对象合并成1个对象,相同属性值会被后者覆盖 this.interceptors(instance, options.url) // 拦截器 return instance(options) } } export default HttpRequest ~~~ >[success] ### 注释掉mock.js 把 **main.js** 中引入的 **mock.js** 的引入 **注释掉** ,因为我们要 **请求真实的接口数据** ,如果这个地方引入了 **mock.js** ,它会用 **mock** 对你的 **所有请求行为进行拦截** ,所以需要 **注释掉**,代码如下: **src/main.js** ~~~ import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' import './plugins/element.js' import Bus from './lib/bus' // 非生产环境时引入 mock // if(process.env.NODE_ENV !== 'production') require('./mock') Vue.config.productionTip = false Vue.prototype.$bus = Bus new Vue({ router, store, render: h => h(App) }).$mount('#app') ~~~ >[success] ### 配置代理 因为 **express** 起的 **后端服务URL** 是 **http://localhost:3000/** ,而 **前端服务URL** 是 **http://localhost:8080/** ,两者的 **端口号不同(后端端口:3000,前端端口:8080)** ,所以调用接口时候会 **产生跨域** ,这时候在 **后端没有添加header头** , **前端解决跨域需要配置代理**,首先在 **vue.config.js** 中把 **devServer 中 proxy** 修改成 **要代理的后端服务地址** 如下: **vue.config.js** ~~~ const path = require('path') // 引入nodejs的path模块 const resolve = dir => path.join(__dirname, dir) // resolve方法用来加载路径 const BASE_URL = process.env.NODE_ENV === 'production' ? '/iview-admin/' : '/' // 判断当前为开发环境还是打包环境, '/'意思是代表指定在域名的根目录下,如果要指定到iview-admin下就这样写'/iview-admin/', production为生产坏境,development为开发环境 module.exports = { lintOnSave: false, // 取消每次保存时都进行一次' ESLint '检测 publicPath: BASE_URL, // 项目的基本路径,vuecli2.0时打包经常静态文件找不到,就是需要配置这个属性为'./' chainWebpack: config => { // 配置Webpack config.resolve.alias .set('@', resolve('src')) // 引入文件时候“ @ ”符号就代表src .set('_c', resolve('src/components')) // 引入组件文件夹中的文件就可以用“ _c ”代替src/components }, productionSourceMap: false, // 打包时不生成.map文件,会减少打包体积,同时加快打包速度 devServer: { // 跨域有2种解决方案: 1. 在后端的header中配置, 2. 使用devServer来配置代理解决跨域 proxy: 'http://localhost:3000/' // 这里写需要代理的URL,这里会告诉开发服务器,将任何未知请求匹配不到静态文件的请求,都代理到这个URL来满足跨域 } } ~~~ 然后修改一下 **axios配置文件** 中的 **baseURL** 判断逻辑,如果是 **开发环境 baseURL 就设置为空字符串** ,因为 **不设置为空字符串,baseURL 不会被代理里配置的URL更改**。 **src/config/index.js** ~~~ // 如果当前是生产环境用生产环境地址,如果是开发环境并且在vue.config.js中配置了代理,就用空字符串【''】,如果未配置代理就用开发环境地址 export const baseURL = process.env.NODE_ENV === 'production' ? 'http://production.com' : '' ~~~ **注意:如果 修改了webpack 的配置 ,必须要 重启前端的服务,才会生效 。** >[success] ## Token过期处理 一个网站如果 **长时间没有操作** ,不可能让它 **登陆完一次,就一辈子不用再登陆了,就一直能用** ,我们会 **设置一个token的过期时间 ,过期之后需要重新登录** 。例如:用户 **登陆成功** 后获取到了 **token** ,**token 过期时间为一天** ,在这一天都在频繁的使用该网站,到一天了 **token过期** 就 **让用户跳出去重新登录** ,这样 **用户体验不好** ,我们希望 **当用户长时间使用网站时,我们应该给 token 续命,给它延长使用时间** ,每次 **页面的跳转** 都会调用 **authorization接口** ,**authorization接口** 每次都会返回一个 **新的token** ,它会 **重新计时**。 **src/store/module/user.js** ~~~ // 接口引入 import { authorization } from '@/api/user' // 引入业务类方法 import { setToken } from '@/lib/util' const state = {} const mutations = {} const actions = { /** * 校验token是否失效 * @param commit 执行一个mutation方法来修改state * @param token token信息 */ authorization({ commit }, token){ return new Promise((resolve, reject) => { authorization().then(res => { if(parseInt(res.code) === 401){ reject(new Error('token error')) } else { setToken(res.data.token) // 重新设置token resolve() } }).catch(error => { reject(error) }) }) } } export default { state, mutations, actions } ~~~ >[success] ## 退出登陆 在 **首页** 写个 **退出按钮** ,**点击按钮** 时候执行 **setToken('')** 把 **cookie清空** ,但是为了 **保持代码的一致性** ,还是把这个 **登出方法** 写在 **vuex** 中进行调用, **清空cookie** 后跳转到 **登录页面** 即可。 1. **首页代码** **src/views/Home.vue** ~~~ <template> <div> <h1>首页</h1> <button @click="handleLogout">退出登录</button> </div> </template> <script> import { mapActions } from 'vuex' export default { methods: { // 引入vuex中的logout方法 ...mapActions([ 'logout' ]), // 退出登录 handleLogout(){ this.logout() this.$router.push({ name: 'login' }) } } } </script> ~~~ 2. **vuex模块中代码** **src/store/module/user.js** ~~~ // 接口引入 import { login, authorization } from '@/api/user' // 引入业务类方法 import { setToken } from '@/lib/util' const state = {} const mutations = {} const actions = { /** * 登陆方法 * @param commit 执行一个mutation方法来修改state * @param params this.$store.dispatch('login', '我是params')时传的参数 */ login({ commit }, { userName, password }){ return new Promise((resolve, reject) => { // 登陆接口 login({ userName, password }).then(res => { if(res.code === 200 && res.data.token){ setToken(res.data.token) resolve() } else { reject(new Error('错误')) } }).catch(error => { reject(error) }) }) }, /** * 校验token是否失效 * @param commit 执行一个mutation方法来修改state * @param token token信息 */ authorization({ commit }, token){ return new Promise((resolve, reject) => { authorization().then(res => { if(parseInt(res.code) === 401){ reject(new Error('token error')) } else { resolve() } }).catch(error => { reject(error) }) }) }, /** * 登出方法 */ logout(){ setToken('') } } export default { state, mutations, actions } ~~~ >[success] ## 总结 1. **安全性要求不高** :上面的方案适合用于 **安全性要求不高的情况** 。 2. **安全性要求高** :如果你的系统 **对安全性要求比较高** ,就不能使用上面的方案, **不能使用 js 取到 token 存入到 cookie 中,不能进行一些判断 cookie 里的 token 的逻辑** ,需要 **在服务端设置一个开启 httpOnly,httpOnly设置为 true 后,就只能通过服务端来把 token 设置到 cookie 中了,无法通过 js 脚本来读取操作这个 cookie了,这样就能避免一些跨站脚本攻击。**