wuyuedefeng / blogs

博客文章在issue中
5 stars 0 forks source link

Javascript 微信jssdk签名 #81

Open wuyuedefeng opened 4 years ago

wuyuedefeng commented 4 years ago

公众号配置

签名算法(需要缓存,请自行添加)

// wechatJSSDKSignature.js
const crypto = require('crypto')
// npm i axios
const axios = require('axios')

const URLs = {
  tokenBaseUrl: 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential',
  ticketBaseUrl: 'https://api.weixin.qq.com/cgi-bin/ticket/getticket'
}

// params = { appId, appSecret }
async function getAccessToken (params) {
  const res = await axios.get(`${URLs.tokenBaseUrl}&appid=${params.appId}&secret=${params.appSecret}`)
  if (res.data['access_token']) {
    return res.data['access_token']
  }
  throw new Error(res.data['errmsg'] || 'request accessToken error')
}

async function getTicket (accessToken) {
  const res = await axios.get(`${URLs.ticketBaseUrl}?access_token=${accessToken}&type=jsapi`)
  if (res.data['ticket']) {
    return res.data['ticket']
  }
  throw new Error(res.data['errmsg'] || 'request ticket error')
}

function createSha1SignatureConfig (ticket, locationHref) {
  const data = {
    'jsapi_ticket': ticket,
    nonceStr: Math.random().toString(36).substr(2, 15),
    timestamp: parseInt(new Date().getTime() / 1000),
    url: locationHref
  }
  const rawData = raw(data)
  const sha1 = crypto.createHash('sha1')
  sha1.update(rawData)
  data['signature'] = sha1.digest('hex')
  delete data.jsapi_ticket
  delete data.url
  return data

  function raw (args) {
    const keys = Object.keys(args).sort()
    const newArgs = []
    keys.forEach((key) => {
      newArgs.push([key.toLowerCase(), args[key]].join('='))
    })
    return newArgs.join('&')
  }
}

// params = { appId, appSecret, locationHref }
async function getJSSDKSignatureConfig (params) {
  const accessToken = await getAccessToken(params)
  const ticket = await getTicket(accessToken)
  const config = createSha1SignatureConfig(ticket, decodeURIComponent(params['locationHref'].split('#')[0]))
  // config = { nonceStr, timestamp, signature }
  return config
}

module.exports = getJSSDKSignatureConfig

cache 服务 (个人实现, 也可以借助redis)

// cache.js
class CacheItem {
  constructor (props = { expiresIn: 0, value: null }) {
    this.expiresIn = props.expiresIn // 秒
    this.expiresAt = parseInt(new Date().getTime() / 1000) + this.expiresIn
    this.value = props.value
  }
  // 有效的
  get isValid () {
    return parseInt(new Date().getTime() / 1000) < this.expiresAt
  }
}
let shareInstance = null
module.exports = class Cache {
  static get shareInstance () {
    shareInstance = shareInstance || new Cache()
    return shareInstance
  }
  static fetch (key, expiresIn, blockFn) {
    return Cache.shareInstance.fetch(...arguments)
  }
  static fetchAsync (key, expiresIn, blockFnAsync) {
    return Cache.shareInstance.fetchAsync(...arguments)
  }

  constructor () {
    this.cache = {}
  }
  fetch (key, expiresIn, blockFn) {
    const cacheItem = this.cache[key]
    if (!cacheItem || !cacheItem.isValid) {
      const value = blockFn && blockFn() || null
      this.cache[key] = new CacheItem({ expiresIn, value })
    }
    this.tryClearInvalidData()
    return this.cache[key].value
  }
  async fetchAsync (key, expiresIn, blockFnAsync) {
    const cacheItem = this.cache[key]
    if (!cacheItem || !cacheItem.isValid) {
      // 异步获取值捕获异常,将过期时间设置为0(立即过期)
      const value = blockFnAsync && (await blockFnAsync().catch(e => { expiresIn = 0; throw e })) || null
      this.cache[key] = new CacheItem({ expiresIn, value })
    }
    this.tryClearInvalidData()
    return this.cache[key].value
  }
  // 删除无效数据
  tryClearInvalidData () {
    // 阀值,十分之一可能清理无效缓存
    if (Math.random() > 0.9) {
      setTimeout(() => { // 异步清理
        Object.keys(this.cache).forEach(key => {
          if (!this.cache[key].isValid) {
            delete this.cache[key]
          }
        })
      })
    }
  }
}
wuyuedefeng commented 4 years ago

带有缓存功能的完整签名代码

const crypto = require('crypto')
const axios = require('axios')
const Cache = require('./cache')

const URLs = {
  tokenBaseUrl: 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential',
  ticketBaseUrl: 'https://api.weixin.qq.com/cgi-bin/ticket/getticket'
}

// params = { appId, appSecret }
async function getAccessToken (params) {
  const res = await axios.get(`${URLs.tokenBaseUrl}&appid=${params.appId}&secret=${params.appSecret}`)
  if (res.data['access_token']) {
    return res.data['access_token']
  }
  throw new Error(res.data['errmsg'] || 'request accessToken error')
}

async function getTicket (accessToken) {
  const res = await axios.get(`${URLs.ticketBaseUrl}?access_token=${accessToken}&type=jsapi`)
  if (res.data['ticket']) {
    return res.data['ticket']
  }
  throw new Error(res.data['errmsg'] || 'request ticket error')
}

function createSha1SignatureConfig (ticket, locationHref) {
  const data = {
    nonceStr: Math.random().toString(36).substr(2, 15),
    timestamp: parseInt(new Date().getTime() / 1000)
  }
  const rawData = raw(Object.assign({ url: locationHref, jsapi_ticket: ticket }, data))
  const sha1 = crypto.createHash('sha1')
  sha1.update(rawData)
  data['signature'] = sha1.digest('hex')
  return data

  function raw (args) {
    const keys = Object.keys(args).sort()
    const newArgs = []
    keys.forEach((key) => {
      newArgs.push([key.toLowerCase(), args[key]].join('='))
    })
    return newArgs.join('&')
  }
}

// params = { appId, appSecret, locationHref }
async function getJSSDKSignatureConfig (params) {
  const accessToken = await Cache.fetchAsync(`wechat_jssdk_accessToken_${params.appId}`, 7000, async () => {
    return await getAccessToken(params) // 微信7200秒过期,个人缓存少于该值
  })
  const ticket = await Cache.fetchAsync(`wechat_jssdk_ticket_${params.appId}`, 7000, async () => {
    return await getTicket(accessToken) // 微信7200秒过期,个人缓存少于该值
  })
  // 这里只缓存ticket也可,因为只有ticket失效才会重新拿accessToken换ticket,要注意其他场景是否也会用到accessToken
  // const ticket = await Cache.fetchAsync(`wechat_jssdk_ticket_${params.appId}`, 7000, async () => {
  //   const accessToken = await getAccessToken(params)
  //   return await getTicket(accessToken) // 微信7200秒过期,个人缓存少于该值
  // })
  const config = createSha1SignatureConfig(ticket, decodeURIComponent(params['locationHref'].split('#')[0]))
  // config = { nonceStr, timestamp, signature }
  return config
}

module.exports = getJSSDKSignatureConfig
wuyuedefeng commented 4 years ago

Koa server

const CONFIG = require('./config')
const Koa = require('koa');
const router = require('koa-router')()
const logger = require('koa-logger')
const app = new Koa()
app.use(logger())

const getJSSDKSignatureConfig = require('./libs/getJSSDKSignatureConfig')

app.use(async (ctx, next) => {
  const start = Date.now()
  await next()
  const ms = Date.now() - start
  ctx.set('X-Response-Time', `${ms}ms`);
})

app.use(async (ctx, next) => {
  try {
    await next()
  } catch (err) {
    ctx.throw(422, err.message)
  }
})

router.get('/wechatSignature', async (ctx)=>{
  const appSecret = CONFIG.appSecrets[ctx.query.appId]
  if (!ctx.query.appId || !appSecret) return ctx.throw(422, 'appId invalid')
  if (!ctx.query.locationHref) return ctx.throw(422, 'locationHref required')
  const signatureConfig = await getJSSDKSignatureConfig({...ctx.query, appSecret})
  ctx.body = signatureConfig
})

app.use(router.routes())
app.use(router.allowedMethods())

const port = 5000
app.listen(port, () => {
  console.log(`app start on ${ port }`)
})

config.js

{
  "appSecrets": {
    "wxcbaac81f3xxxx": "625978a0d1f3f9391136xxxxxxxxx"
  }
}
wuyuedefeng commented 3 years ago

自定义Cache,在每个实例中单独存在,微信获取到accessToken,旧的accessToken会在5分钟后过期,在多实例集群的情况下,请使用redis缓存数据

// https://github.com/luin/ioredis
const Redis = require('ioredis')
const redisClient = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
  keyPrefix: "wechat:",
  reconnectOnError(err) {
    console.error(err)
    return true
  }
})

class RedisCache {
  static async getWithInitValueWithLock(key, initValue, lockExpiresSeconds = 5) {
    const exists = await redisClient.exists(key)
    let value = null
    if (exists) {
      value = await redisClient.get(key)
    } else {
      if (typeof initValue === 'function') {
        // 获取锁, set(key, value, 'NX', 'EX', seconds) <=> setnx(key, value) && expire(key, seconds)
        const lockKey = `${key}:with_lock`
        if (await redisClient.set(lockKey, 1, 'NX', 'EX', lockExpiresSeconds)) { // 已获得锁
          value = await initValue().finally(async () => {
            await redisClient.del(lockKey)
          })
        } else { // 未获取到锁
          // 等待500ms 重新获取或者设置值
          await new Promise(resolve => setTimeout(() => { resolve() }, 500))
          return await this.getWithInitValueWithLock(...arguments)
        }
      } else {
        value = initValue
      }
    }
    return value
  }

  // expiresIn: second
  static async fetchAsync (key, expiresIn, blockFnAsync) {
    return await this.getWithInitValueWithLock(key, async () => {
      // 异步获取值捕获异常
      const value = blockFnAsync && (await blockFnAsync()) || null
      redisClient.set(key, value, 'EX', expiresIn)
      return value
    })
  }
  static async clearAsync(key) {
    if (key) {
      await redisClient.del(key)
    }
  }
}
module.exports = RedisCache