@next-auth-oauth/wechatmp
Version:
基于Auth.js的微信公众号验证码登录、二维码扫描登录插件
205 lines (204 loc) • 7.22 kB
JavaScript
import { WechatMpApi } from 'wechatmp-kit';
import { QrcodePage } from './pages/qrcode';
function isBlank(str) {
return str === undefined || str === null || str.trim() === '';
}
/**
* 微信公众号登录管理器
* 用于处理微信公众号登录流程,包括二维码生成、验证码管理、消息处理等
* 支持二维码扫描和消息验证两种登录方式
*/
export class WechatMpLoginManager {
wechatMpApi;
captchaManager;
checkType = 'MESSAGE';
qrcodeImageUrl;
messageServicde;
endpoint;
constructor(config) {
const { checkType = 'MESSAGE', qrcodeImageUrl } = config;
// 验证MESSAGE
if (checkType === 'MESSAGE' && isBlank(qrcodeImageUrl)) {
throw new Error('checkType为MESSAGE时,必须配置qrcodeImageUrl');
}
this.checkType = checkType;
this.qrcodeImageUrl = qrcodeImageUrl;
this.wechatMpApi = new WechatMpApi({
appId: config.appId,
appSecret: config.appSecret,
});
this.captchaManager = config.captchaManager;
this.messageServicde = this.wechatMpApi.getMessageService(config.token, config.aesKey);
this.endpoint = config.endpoint ?? '/api/auth/wechatmp';
console.log('👏[auth.js/微信公众号登录插件]👏');
console.log('请注意以下参数');
console.log(`微信端消息回调:${this.endpoint}`);
console.log(`微信端消息验证类型:${this.checkType}`);
if (this.checkType === 'QRCODE') {
console.log(`⚠️ 注意:只有认证的微信号才能使用二维码登录!🔐`);
}
}
/**
* 创建验证码
* @returns {Promise<string>} 生成的验证码
*/
async createCaptcha() {
return this.captchaManager.generate();
}
/**
* 验证验证码并绑定openid
* @param {string} openid 用户的openid
* @param {string} captcha 用户输入的验证码
* @returns {Promise<boolean>} 验证结果
*/
async verifyCaptcha(openid, captcha) {
return this.captchaManager.updateData(captcha, { openid });
}
/**
* 渲染关注二维码HTML页面
* @returns {string} 渲染后的HTML
*/
async qrcodeAction(request) {
const link = new URL(request.url);
const captcha = await this.createCaptcha();
let imgLink = this.qrcodeImageUrl;
if (this.checkType === 'QRCODE') {
const t = await this.messageServicde.createPermanentQrcode(captcha);
imgLink = t.url;
}
const redirectUri = link.searchParams.get('redirect_uri');
const html = QrcodePage({
checkType: this.checkType,
qrcode: imgLink,
code: captcha,
redirectUri,
checkLoginApi: this.endpoint + '?action=check',
});
return new Response(html, {
headers: {
'Content-Type': 'text/html',
},
});
}
/**
* 处理next-auth的token请求,返回openid
* @param request
* @returns
*/
async tokenAction(request) {
const data = await request.formData();
const valid = await this.captchaManager.getData(data.get('code')?.toString() ?? '');
if (valid?.openid) {
return Response.json({
scope: 'openid',
access_token: valid.openid,
token_type: 'bearer',
});
}
return Response.json({
error: 'invalid_grant',
error_description: '验证码错误',
});
}
/**
* 二维码页面,轮训获取openid
* @param request
* @returns
*/
async checkAction(request) {
const { code } = await request.json();
try {
const exist = await this.captchaManager.exists(code);
if (!exist) {
return Response.json({ type: 'fail', error: '验证码不存在' });
}
const valid = await this.captchaManager.getData(code);
if (valid?.openid) {
return Response.json({ type: 'success' });
}
else {
return Response.json({ type: 'wait' });
}
}
catch (error) {
return Response.json({ type: 'fail', error: JSON.stringify(error) });
}
}
/**
* 处理微信消息(webhook)
* @param {any} message 微信消息内容
* @returns {Promise<void>}
*/
async handleWechatMessage(request) {
const link = new URL(request.url);
const timestamp = link.searchParams.get('timestamp');
const nonce = link.searchParams.get('nonce');
const signature = link.searchParams.get('signature');
const echo = link.searchParams.get('echostr');
if (timestamp && nonce && signature && echo) {
if (this.messageServicde.checkSign({ timestamp, nonce, signature })) {
return new Response(echo);
}
return new Response('验证失败', { status: 405 });
}
// 获得xml消息报
const msg_signature = link.searchParams.get('msg_signature');
const encrypt_type = link.searchParams.get('encrypt_type');
const body = await request.text();
const message = this.messageServicde.parserInput(body, {
timestamp,
nonce,
signature: (encrypt_type === 'aes' ? msg_signature : signature),
});
let content = '';
if (message.EventKey && message.MsgType == 'event') {
content = message.EventKey.replace('qrscene_', '');
}
else if (message.MsgType == 'text') {
content = message.Content.trim();
}
const status = await this.captchaManager.updateData(content, {
openid: message.FromUserName,
});
const result = this.messageServicde.renderMessage({
ToUserName: message.FromUserName,
FromUserName: message.ToUserName,
CreateTime: Math.floor(Date.now() / 1000),
MsgType: 'text',
Content: status ? '👏👏登录成功' : '😭登录失败,请重新获得验证码',
});
return new Response(result, {
headers: {
'Content-Type': 'application/xml',
},
});
}
/**
* 适配NextAuth的authorization页面
* @param {string} captcha 验证码
* @returns {string} 回调URL
*/
getAuthorizationUrl(captcha) {
return `/api/auth/callback/wechatmp?code=${captcha}`;
}
/**
* 暴露给nextjs的处理函数
* @param request
* @returns
*/
handle(request) {
const link = new URL(request.url);
const action = link.searchParams.get('action');
if (action === 'qrcode') {
return this.qrcodeAction(request);
}
if (action === 'token') {
return this.tokenAction(request);
}
if (action === 'check') {
return this.checkAction(request);
}
// 验证消息
return this.handleWechatMessage(request);
}
}