'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) { super(ctx, {}, _.get(ctx.app.config, 'wxapi')); this.prefix = 'visit-auth:'; this.jsapiKey = 'visit-access_token'; this.wxInfo = ctx.app.config.wxapi; this.authBackUrl = `${ctx.app.config.baseUrl}/api/visit/authBack`; } /** * 网页授权 * @param {Object} query 参数 */ async auth(query) { const { redirect_uri, ...others } = query; const { appid } = this.wxInfo; 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 }); 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_base&state=${state}#wechat_redirect`; console.log(`url=>${url}`); this.ctx.redirect(url); } /** * 网页授权回调,获取openid * @param {Object} query 参数 */ async authBack(query) { const { code, state } = query; if (!code) throw new BusinessError(ErrorCode.SERVICE_FAULT, '授权未成功'); const { appid, appSecret } = this.wxInfo; const url = 'https://api.weixin.qq.com/sns/oauth2/access_token'; const params = { appid, secret: appSecret, code, grant_type: 'authorization_code', }; const req = await this.httpGet(url, params); 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; redirect_uri = urljoin(redirect_uri, `?openid=${openid}`); this.ctx.redirect(redirect_uri); } /** * JsApi验证 * @param {Object} query 参数 */ async jsapiAuth(query) { let { url } = query; url = decodeURIComponent(url); let jsapi_ticket = await this.app.redis.get(this.jsapiKey); const { appid, appSecret } = this.wxInfo; 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(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;