'use strict'; const assert = require('assert'); const uuid = require('uuid'); const random = require('string-random'); const crypto = require('crypto'); const urljoin = require('url-join'); const _ = require('lodash'); const moment = require('moment'); const { BusinessError, ErrorCode } = require('naf-core').Error; const jwt = require('jsonwebtoken'); const { AxiosService } = require('naf-framework-mongoose/lib/service'); class WeixinAuthService extends AxiosService { constructor(ctx, options) { super(ctx, {}, _.get(ctx.app.config, 'wxapi', {})); this.prefix = 'auth:'; this.jsapiKey = 'jsapi_ticket'; this.wxInfo = ctx.app.config.wxapi; this.authBackUrl = `${ctx.app.config.baseUrl}/wxgateway/authBack`; } /** * 网页授权 * @param {String} site 公众号标识,对config中设置 * @param {Object} query 参数 */ async auth({ site }, query) { const { redirect_uri, ...others } = query; const { baseUrl } = this.wxInfo; if (!this.wxInfo[site]) throw new BusinessError(ErrorCode.ACCESS_DENIED, '缺少公众号站点设置'); const { appid } = this.wxInfo[site]; if (!appid) { throw new BusinessError(ErrorCode.SERVICE_FAULT, '缺少公众号设置'); } // 用于redis const state = uuid.v4(); const key = `${this.prefix}${state}`; const val = JSON.stringify({ ...others, redirect_uri, site }); await this.app.redis.set(key, val, 'EX', 600); // const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${this.authBackUrl}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`; let backUrl; if (this.authBackUrl.startsWith('http')) { backUrl = encodeURI(`${this.authBackUrl}?state=${state}`); } else { backUrl = encodeURI(`${this.ctx.protocol}://${this.ctx.host}${this.authBackUrl}?state=${state}`); } const to_uri = `${baseUrl}/api/auth?appid=${appid}&response_type=code&redirect_uri=${backUrl}#wechat`; this.ctx.redirect(to_uri); } /** * 网页授权回调,获取openid * @param {Object} query 参数 */ async authBack(query) { const { code, state } = query; if (!code) throw new BusinessError(ErrorCode.SERVICE_FAULT, '授权未成功'); const req = await this.httpGet('/api/fetch', { code }); if (req.errcode && req.errcode !== 0) throw new BusinessError(ErrorCode.SERVICE_FAULT, 'openid获取失败'); const openid = _.get(req, 'openid'); if (!openid) { this.ctx.logger.error(JSON.stringify(req.data)); throw new BusinessError(ErrorCode.SERVICE_FAULT, '未获取到openid'); } // 验证获取openid结束,接下来应该返回前端 const key = `${this.prefix}${state}`; let fqueries = await this.app.redis.get(key); if (fqueries)fqueries = JSON.parse(fqueries); let { redirect_uri } = fqueries; const queryStr = `?openid=${openid}`; redirect_uri = urljoin(redirect_uri, queryStr); this.ctx.redirect(redirect_uri); } /** * 换微信用户信息 * @param {String} site 公众号标识,对config中设置 * @param {Object} {openid} */ async wxUser({ site }, { openid }) { if (!this.wxInfo[site]) throw new BusinessError(ErrorCode.ACCESS_DENIED, '缺少公众号站点设置'); const { appid } = this.wxInfo[site]; const res = await this.httpGet('/api.weixin.qq.com/cgi-bin/user/info?lang=zh_CN', { appid, openid }); const object = _.pick(res, [ 'nickname', 'headimgurl', 'openid' ]); // 昵称,头像,openid return { name: object.nickname, icon: object.headimgurl, openid }; } /** * JsApi验证 * @param {String} site 公众号标识,对config中设置 * @param {Object} query 参数 */ async jsapiAuth({ site }, query) { let { url } = query; url = decodeURIComponent(url); let jsapi_ticket = await this.app.redis.get(`${site}:${this.jsapiKey}`); if (!this.wxInfo[site]) throw new BusinessError(ErrorCode.ACCESS_DENIED, '缺少公众号站点设置'); const { appid, appSecret } = this.wxInfo[site]; if (!jsapi_ticket) { // 1,获取access_token const atUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${appSecret}`; const req = await this.ctx.curl(atUrl, { method: 'GET', dataType: 'json' }); if (req.status !== 200) throw new BusinessError(ErrorCode.SERVICE_FAULT, 'access_token获取失败'); const access_token = _.get(req, 'data.access_token'); // 2,获取jsapi_token const jtUrl = `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${access_token}&type=jsapi`; const jtReq = await this.ctx.curl(jtUrl, { method: 'GET', dataType: 'json' }); if (jtReq.status !== 200) throw new BusinessError(ErrorCode.SERVICE_FAULT, 'jsapi_ticket获取失败'); jsapi_ticket = _.get(jtReq, 'data.ticket'); // 实际过期时间是7200s(2h),系统默认设置6000s const expiresIn = _.get(jtReq, 'data.expires_in', 6000); // 缓存jsapi_ticket,重复使用 await this.app.redis.set(`${site}:${this.jsapiKey}`, jsapi_ticket, 'EX', expiresIn); } const noncestr = random(16).toLowerCase(); const timestamp = moment().unix(); const signStr = `jsapi_ticket=${jsapi_ticket}&noncestr=${noncestr}×tamp=${timestamp}&url=${url}`; const sign = crypto.createHash('sha1').update(signStr).digest('hex'); return { jsapi_ticket, noncestr, timestamp, sign, appid, url }; } async createJwt({ openid, nickname, subscribe }) { const { secret, expiresIn = '1d', issuer = 'weixin' } = this.config.jwt; const subject = openid; const userinfo = { nickname, subscribe }; const token = await jwt.sign(userinfo, secret, { expiresIn, issuer, subject }); return token; } /** * 创建二维码 * 随机生成二维码,并保存在Redis中,状态初始为pending * 状态描述: * pending - 等待扫码 * consumed - 使用二维码登录完成 * scand:token - Jwt登录凭证 */ async createQrcode() { const qrcode = uuid(); const key = `visit:qrcode:group:${qrcode}`; await this.app.redis.set(key, 'pending', 'EX', 600); return qrcode; } /** * 创建二维码 * 生成群二维码 * 状态描述: * pending - 等待扫码 * consumed - 使用二维码登录完成 * scand:token - Jwt登录凭证 */ async createQrcodeGroup({ groupid }) { const { authUrl = this.ctx.path } = this.app.config; let backUrl; if (authUrl.startsWith('http')) { backUrl = encodeURI(`${authUrl}?state=${groupid}`); } else { backUrl = encodeURI(`${this.ctx.protocol}://${this.ctx.host}${authUrl}?state=${groupid}`); } console.log(backUrl); return backUrl; } /** * 扫码登录确认 */ async scanQrcode({ qrcode, token }) { assert(qrcode, 'qrcode不能为空'); assert(token, 'token不能为空'); const key = `smart:qrcode:login:${qrcode}`; const status = await this.app.redis.get(key); if (!status) { throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码已过期'); } if (status !== 'pending') { throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码状态无效'); } // 验证Token const { secret } = this.config.jwt; const decoded = jwt.verify(token, secret, { issuer: 'weixin' }); this.ctx.logger.debug(`[weixin] qrcode login - ${decoded}`); // TODO: 修改二维码状态,登录凭证保存到redis await this.app.redis.set(key, `scaned:${token}`, 'EX', 600); // TODO: 发布扫码成功消息 const { mq } = this.ctx; const ex = 'qrcode.login'; if (mq) { await mq.topic(ex, qrcode, 'scaned', { durable: true }); } else { this.ctx.logger.error('!!!!!!没有配置MQ插件!!!!!!'); } } // 使用二维码换取登录凭证 async qrcodeLogin(qrcode) { assert(qrcode, 'qrcode不能为空'); const key = `smart:qrcode:login:${qrcode}`; const val = await this.app.redis.get(key); if (!val) { throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码已过期'); } const [ status, token ] = val.split(':', 2); if (status !== 'scaned' || !token) { throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码状态无效'); } // TODO: 修改二维码状态 await this.app.redis.set(key, 'consumed', 'EX', 600); return { token }; } // 检查二维码状态 async checkQrcode(qrcode) { assert(qrcode, 'qrcode不能为空'); const key = `smart:qrcode:login:${qrcode}`; const val = await this.app.redis.get(key); if (!val) { throw new BusinessError(ErrorCode.SERVICE_FAULT, '二维码已过期'); } const [ status ] = val.split(':', 2); return { status }; } } module.exports = WeixinAuthService;