|
@@ -0,0 +1,222 @@
|
|
|
|
+'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 = 'auth:';
|
|
|
|
+ this.jsapiKey = 'jsapi_ticket';
|
|
|
|
+ this.access_tokenKey = 'access_token';
|
|
|
|
+ this.wxInfo = ctx.app.config.wxapi;
|
|
|
|
+ this.authBackUrl = `${ctx.app.config.baseUrl}/api/article/authBack`;
|
|
|
|
+ }
|
|
|
|
+ /**
|
|
|
|
+ * 网页授权
|
|
|
|
+ * @param {Object} query 参数
|
|
|
|
+ */
|
|
|
|
+ async auth(query) {
|
|
|
|
+ const { redirect_uri, ...others } = query;
|
|
|
|
+ const { appid, baseUrl } = 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_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 {Object} {openid}
|
|
|
|
+ */
|
|
|
|
+ async wxUser({ openid }) {
|
|
|
|
+ const { appid } = this.wxInfo;
|
|
|
|
+ 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 object;
|
|
|
|
+ }
|
|
|
|
+ /**
|
|
|
|
+ * 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;
|