vue 项目优雅的对 url 参数加密

实现方案:stringifyQuery 和 parseQuery

近期因为公司内部的安全检查,说我们现在的系统中参数是明文的,包括给后端请求的参数和前端页面跳转携带的参数,因为是公司内部使用的系统,在安全性方面的设计考虑确实不够充分

对于参数的加密和解密很好实现,直接采用常用的 AES 算法,前后端定义好通用的密钥和加解密方式就好,前端加解密这里主要使用到 crypto-js 这个工具包,再通过一个类简单封装一下加解密的算法即可

1// src\utils\cipher.ts 2import { encrypt, decrypt } from 'crypto-js/aes' 3import { parse } from 'crypto-js/enc-utf8' 4import pkcs7 from 'crypto-js/pad-pkcs7' 5import ECB from 'crypto-js/mode-ecb' 6import UTF8 from 'crypto-js/enc-utf8' 7 8// 注意 key 和 iv 至少都需要 16 位 9const AES_KEY = '1111111111000000' 10const AES_IV = '0000001111111111' 11 12export class AesEncryption { 13 private key 14 private iv 15 16 constructor(key = AES_KEY, iv = AES_IV) { 17 this.key = parse(key) 18 this.iv = parse(iv) 19 } 20 21 get getOptions() { 22 return { 23 mode: ECB, 24 padding: pkcs7, 25 iv: this.iv, 26 } 27 } 28 29 encryptByAES(text: string) { 30 return encrypt(text, this.key, this.getOptions).toString() 31 } 32 33 decryptByAES(text: string) { 34 return decrypt(text, this.key, this.getOptions).toString(UTF8) 35 } 36}

对于前端页面间跳转携带参数,我们项目使用的都是 vue-router 的 query 来携带参数,但是有那么多页面跳转的地方,不可能都手动添加加解密方法处理吧,工作量大不说,万一漏改一个就可能导致整个页面无法加载了,这锅可不能背

首先想到的方法是在路由守卫 beforeEach 中对参数进行加密,然后在 afterEach 守卫中对参数进行解密,但是这个想法在 beforeEach 中加密就无法实现。原因是 beforeEach(to, from, next) 的第三个参数 next 函数中,如果参数是路由对象,会导致跳转死循环

接下来经过几个小时百思不得其解(~~摸鱼~~)之后,最终在 API 参考 | Vue Router (vuejs.org) 找到这样两个 API:stringifyQueryparseQuery,官网的定义如下

stringifyQuery:对查询对象进行字符串化的自定义实现。不应该在前面加上 ?。应该正确编码查询键和值

parseQuery:用于解析查询的自定义实现。必须解码查询键和值

比如,官网建议如果想使用 qs 包来解析查询,可以这样配置

1import qs from 'qs' 2 3createRouter({ 4 // 其他配置... 5 parseQuery: qs.parse, 6 stringifyQuery: qs.stringify, 7})

现在最终的解决方案就很明确了,自定义两个参数加密、解密的方法,然后在 createRouter 中添加到 stringifyQueryparseQuery 这两个方法就可以了,下面是详细代码

1// src/router/helper/query.js 2import { isArray, isNull, isUndefined } from 'lodash-es' 3import { AesEncryption } from '@/utils/cipher' 4import type { 5 LocationQuery, 6 LocationQueryRaw, 7 LocationQueryValue, 8} from 'vue-router' 9 10const aes = new AesEncryption() 11 12/** 13 * 14 * @description 解密:反序列化字符串参数 15 */ 16export function stringifyQuery(obj: LocationQueryRaw): string { 17 if (!obj) return '' 18 19 const result = Object.keys(obj) 20 .map((key) => { 21 const value = obj[key] 22 23 if (isUndefined(value)) return '' 24 25 if (isNull(value)) return key 26 27 if (isArray(value)) { 28 const resArray: string[] = [] 29 30 value.forEach((item) => { 31 if (isUndefined(item)) return 32 33 if (isNull(item)) { 34 resArray.push(key) 35 } else { 36 resArray.push(key + '=' + item) 37 } 38 }) 39 return resArray.join('&') 40 } 41 42 return `${key}=${value}` 43 }) 44 .filter((x) => x.length > 0) 45 .join('&') 46 47 return result ? `?${aes.encryptByAES(result)}` : '' 48} 49 50/** 51 * 52 * @description 解密:反序列化字符串参数 53 */ 54export function parseQuery(query: string): LocationQuery { 55 const res: LocationQuery = {} 56 57 query = query.trim().replace(/^(\?|#|&)/, '') 58 59 if (!query) return res 60 61 query = aes.decryptByAES(query) 62 63 query.split('&').forEach((param) => { 64 const parts = param.replace(/\+/g, ' ').split('=') 65 const key = parts.shift() 66 const val = parts.length > 0 ? parts.join('=') : null 67 68 if (!isUndefined(key)) { 69 if (isUndefined(res[key])) { 70 res[key] = val 71 } else if (isArray(res[key])) { 72 ;(res[key] as LocationQueryValue[]).push(val) 73 } else { 74 res[key] = [res[key] as LocationQueryValue, val] 75 } 76 } 77 }) 78 79 return res 80} 81 82// src/router/index.js 83// 创建路由使用加解密方法 84import { parseQuery, stringifyQuery } from './helper/query' 85 86export const router = createRouter({ 87 // 创建一个 hash 历史记录。 88 history: createWebHashHistory(import.meta.env.VITE_PUBLIC_PATH), 89 routes: basicRoutes, 90 scrollBehavior: () => ({ left: 0, top: 0 }), 91 stringifyQuery, // 序列化query参数 92 parseQuery, // 反序列化query参数 93})

加密的效果如下,我也在 github 上传了加密方式的 demo,可以直接下载体验一下

更进一步:相关实现原理

在实现完这两个功能之后,我突然想翻一下 Vue Router 的源码,看一下 stringifyQueryparseQuery 的实现原理,避免以后遇到类似的问题再抓瞎

打开 Vue Router@4的源码,整个项目是用 pnpm 管理 monorepo 的方式组织,通过 rollup.config.js 中定义的 input 入口可以知道,所有的方法都通过 packages/router/src/index.ts 导出

首先先看初始化路由实例的 createRouter 方法,这个方法主要做了这么几件事

  1. 通过 createRouterMatcher 方法,根据路由配置列表创建 matcher,返回 5 个操作 matcher 方法。matcher 可以理解为路由页面匹配器,包含路由所有信息和 crud 操作方法
  2. 定义三个路由守卫:beforeEach、beforeResolve、afterEach
  3. 声明当前路由 currentRoute,对 url 参数 paramas 进行编码处理
  4. 添加路由的各种操作方法,最后返回一个 router 对象

一个简化版本的 createRouter 方法如下所示,前文使用到的 stringifyQueryparseQuery 都是在这个方法中加载

1export function createRouter(options: RouterOptions): Router { 2 // 创建路由匹配器 matcher 3 const matcher = createRouterMatcher(options.routes, options) 4 5 // ! 使用到的 stringifyQuery 和 parseQuery 6 const parseQuery = options.parseQuery || originalParseQuery 7 const stringifyQuery = options.stringifyQuery || originalStringifyQuery 8 9 // ! 路由守卫定义 10 const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>() 11 const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>() 12 const afterGuards = useCallbacks<NavigationHookAfter>() 13 14 // 声明当前路由 15 const currentRoute = shallowRef<RouteLocationNormalizedLoaded>( 16 START_LOCATION_NORMALIZED 17 ) 18 let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED 19 20 // leave the scrollRestoration if no scrollBehavior is provided 21 if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) { 22 history.scrollRestoration = 'manual' 23 } 24 25 // url 参数进行编码处理 26 const normalizeParams = applyToParams.bind( 27 null, 28 (paramValue) => '' + paramValue 29 ) 30 const encodeParams = applyToParams.bind(null, encodeParam) 31 const decodeParams: (params: RouteParams | undefined) => RouteParams = 32 applyToParams.bind(null, decode) 33}

从创建路由实例来看, stringifyQueryparseQuery 两个参数如果没有自定义传入的情况下,会使用 vue-router 默认的解析函数

默认的 stringifyQuery 函数用于把参数由对象形式转换为字符串连接形式,主要流程

  1. 循环参数 query 对象
  2. 特殊处理参数为 null 的情况,参数值为 null 的情况会拼接在 url 链接中但是没有值,而参数值为 undefined 则会直接忽略
  3. 将对象转化为数组,并且对每个对象的值进行 encoded 处理
  4. 将数组拼接为字符串参数
1// vue-router 默认的序列化 query 参数的函数 2export function stringifyQuery(query: LocationQueryRaw): string { 3 let search = '' 4 for (let key in query) { 5 const value = query[key] 6 key = encodeQueryKey(key) 7 // 处理参数为 null 的情况 8 if (value == null) { 9 if (value !== undefined) { 10 search += (search.length ? '&' : '') + key 11 } 12 continue 13 } 14 // 将参数处理为数组,便于后续统一遍历处理 15 const values: LocationQueryValueRaw[] = isArray(value) 16 ? value.map((v) => v && encodeQueryValue(v)) 17 : [value && encodeQueryValue(value)] 18 19 values.forEach((value) => { 20 // 跳过参数为 undefined 的情况,只拼接有值的参数 21 if (value !== undefined) { 22 search += (search.length ? '&' : '') + key 23 if (value != null) search += '=' + value 24 } 25 }) 26 } 27 28 return search 29} 30 31// 示例参数,如下参数会被转换为:name=wujieli&age=12&address 32// query: { 33// id: undefined, 34// name: 'wujieli', 35// age: 12, 36// address: null, 37// },

默认的 parseQuery 函数用来将字符串参数解析为对象,主要流程

  1. 排除空字符串和字符串前的 "?"
  2. 对字符串用 "&" 分割,遍历分割后的数组
  3. 根据 "=" 截取参数的 key 和 value,并对 key 和 value 做 decode 处理
  4. 处理 key 重复存在的情况,如果 key 对应 value 是数组,就把 value 添加进数组中,否则就覆盖前一个 value
1// vue-router 默认的序列化 query 参数的函数 2export function parseQuery(search: string): LocationQuery { 3 const query: LocationQuery = {} 4 // 因为要对字符串进行 split('&') 操作,所以优先排除空字符串 5 if (search === '' || search === '?') return query 6 // 排除解析参数前的 ? 7 const hasLeadingIM = search[0] === '?' 8 const searchParams = (hasLeadingIM ? search.slice(1) : search).split('&') 9 10 for (let i = 0; i < searchParams.length; ++i) { 11 // 根据 = 截取参数的 key 和 value,并做 decode 处理 12 const searchParam = searchParams[i].replace(PLUS_RE, ' ') 13 const eqPos = searchParam.indexOf('=') 14 const key = decode(eqPos < 0 ? searchParam : searchParam.slice(0, eqPos)) 15 const value = eqPos < 0 ? null : decode(searchParam.slice(eqPos + 1)) 16 17 // 处理 key 重复存在的情况 18 if (key in query) { 19 // an extra variable for ts types 20 let currentValue = query[key] 21 if (!isArray(currentValue)) { 22 currentValue = query[key] = [currentValue] 23 } 24 // we force the modification 25 ;(currentValue as LocationQueryValue[]).push(value) 26 } else { 27 query[key] = value 28 } 29 } 30 return query 31}

stringifyQuery 这个方法用在创建 router 实例时提供的 resolve 方法中用来生成 url,parseQuery 方法主要用在 router.pushrouter.replace 等方法中解析 url 携带的参数

1// stringifyQuery 方法的使用 2function resolve( 3 rawLocation: Readonly<RouteLocationRaw>, 4 currentLocation?: RouteLocationNormalizedLoaded 5): RouteLocation & { href: string } { 6 // ... 7 // 链接的完整 path,包括路由 path 和后面的完整参数 8 const fullPath = stringifyURL( 9 stringifyQuery, 10 assign({}, rawLocation, { 11 hash: encodeHash(hash), 12 path: matchedRoute.path, 13 }) 14 ) 15} 16 17// parseQuery 方法会封装在 locationAsObject 方法中使用 18function locationAsObject( 19 to: RouteLocationRaw | RouteLocationNormalized 20): Exclude<RouteLocationRaw, string> | RouteLocationNormalized { 21 return typeof to === 'string' 22 ? parseURL(parseQuery, to, currentRoute.value.path) 23 : assign({}, to) 24}

以上就是 stringifyQueryparseQuery 两个方法的实现原理,可以看到源码中对于参数的加密解密考虑的处理是更多的,其实也可以把两个方法的源码拷贝出来,加上加密、解密的方法然后覆盖源码即可