diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2627b8f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "bun run example:dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/.bin/next", + "runtimeArgs": [ + "--inspect" + ], + "skipFiles": [ + "/**" + ], + "serverReadyAction": { + "action": "debugWithEdge", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}" + } + } + ] +} \ No newline at end of file diff --git a/apps/example/package.json b/apps/example/package.json index f74a8a6..66ed962 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -24,6 +24,8 @@ "next": "15.0.1", "next-auth": "beta", "next-auth-oauth": "^1", + "@next-auth-oauth/wechatmp": "*", + "wechatmp-kit": "*", "react": "19.0.0-rc-cae764ce-20241025", "react-dom": "19.0.0-rc-cae764ce-20241025", "tailwind-merge": "^2.5.2" diff --git a/apps/example/src/app/api/auth/wechatmp/route.ts b/apps/example/src/app/api/auth/wechatmp/route.ts new file mode 100644 index 0000000..153d59d --- /dev/null +++ b/apps/example/src/app/api/auth/wechatmp/route.ts @@ -0,0 +1,2 @@ +import { wechatMpProvder } from '@/auth' +export const { GET, POST } = wechatMpProvder diff --git a/apps/example/src/auth.ts b/apps/example/src/auth.ts index 02a5ab8..e3e0190 100644 --- a/apps/example/src/auth.ts +++ b/apps/example/src/auth.ts @@ -5,10 +5,22 @@ import { prisma } from '@/lib/db' import { AuthService } from '@/service/auth.service' import { Gitee } from '@next-auth-oauth/gitee' import Github from 'next-auth/providers/github' - +import Wehcatmp from '@next-auth-oauth/wechatmp' +import { WechatMpApi } from 'wechatmp-kit' import { AuthConfig } from './auth.config' export const authAdapter = PrismaAdapter(prisma) +export const wechatMpProvder = Wehcatmp({ + wechatMpApi: new WechatMpApi({ + appId: process.env.AUTH_WECHATMP_APPID!, + appSecret: process.env.AUTH_WECHATMP_APPSECRET!, + }), + endpoint: 'http://localhost:3000/api/auth/wechatmp', + /** + * 通过消息回复 + */ + checkType: 'QRCODE', +}) export const authService = new AuthService() export const { handlers, @@ -23,7 +35,7 @@ export const { listAccount, } = AdavanceNextAuth({ ...AuthConfig, - providers: [Gitee, Github], + providers: [Gitee, Github, wechatMpProvder], adapter: authAdapter, userService: authService, autoBind: true, diff --git a/bun.lockb b/bun.lockb index fe1239c..69b7b69 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 584cda9..a695d54 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build": "bun --filter 'next-auth-oauth' build", "release": "bun --filter 'next-auth-oauth' release", "dev": "bun --filter '{next-auth-oauth,example}' dev", - "example:build": "bun run --filter '{next-auth-oauth,@next-auth-oauth/gitee,example}' build", - "example:dev": "bun run --filter '{example}' dev", + "example:build": "bun run --filter '{next-auth-oauth,@next-auth-oauth/wechatmp,@next-auth-oauth/gitee,example}' build", + "example:dev": "bun run --filter 'example' dev", "release:wechatmp-kit": "bun run --filter 'wechatmp-kit' release ", "release:weibo": "bun run --filter '@next-auth-oauth/weibo' release ", "release:gitee": "bun run --filter '@next-auth-oauth/gitee' release ", diff --git a/packages/wechatmp-kit/package.json b/packages/wechatmp-kit/package.json index bc3f312..8fbbc40 100644 --- a/packages/wechatmp-kit/package.json +++ b/packages/wechatmp-kit/package.json @@ -1,6 +1,6 @@ { "name": "wechatmp-kit", - "version": "0.1.0", + "version": "0.1.1", "description": "微信公众号工具包", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -29,4 +29,4 @@ "license": "MIC", "peerDependencies": {}, "devDependencies": {} -} \ No newline at end of file +} diff --git a/packages/wechatmp-kit/src/service/MessageService.ts b/packages/wechatmp-kit/src/service/MessageService.ts index 79d7e26..c6de519 100644 --- a/packages/wechatmp-kit/src/service/MessageService.ts +++ b/packages/wechatmp-kit/src/service/MessageService.ts @@ -118,6 +118,8 @@ export class MessageService { } /** * 解析输入消息 + * 1: 如果有AES加密,会先解包获得encrypt,然后解包xml + * 2: 校验签名 */ parserInput(input: string, params: Omit) { if (!this.aesMode) { diff --git a/packages/wechatmp-kit/src/utils.ts b/packages/wechatmp-kit/src/utils.ts index f212031..42b6d00 100644 --- a/packages/wechatmp-kit/src/utils.ts +++ b/packages/wechatmp-kit/src/utils.ts @@ -53,24 +53,3 @@ export function renderXML(data: Record) { xmls.push('') return xmls.join('') } -const t1 = parseWehcatMessageXML< - Record ->(` - - - - 1348831860 - 1348831860 - - - - 1234567890123456 - xxxx - xxxx - - `) -console.log({ t1 }) -const t2 = renderXML(t1) -console.log({ t2 }) -const t3 = parseWehcatMessageXML(t2) -console.log({ t3 }) diff --git a/packages/wechatmp/package.json b/packages/wechatmp/package.json index 929d2e8..5530856 100644 --- a/packages/wechatmp/package.json +++ b/packages/wechatmp/package.json @@ -10,6 +10,7 @@ "docs" ], "scripts": { + "dev": "tsc --declaration --emitDeclarationOnly && bun build ./src/index.ts --target=node --outdir=dist --watch", "build": "bun build ./src/index.ts --target=node --outdir=dist && tsc --declaration --emitDeclarationOnly", "patch:version": "npm version patch", "release": "bun run build && npm publish && npm version patch" @@ -31,7 +32,8 @@ }, "license": "MIC", "peerDependencies": { - "next-auth": "beta" + "next-auth": "beta", + "next": "^15" }, "dependencies": { "wechatmp-kit": "*" diff --git a/packages/wechatmp/src/index.ts b/packages/wechatmp/src/index.ts index ad664b5..318da90 100644 --- a/packages/wechatmp/src/index.ts +++ b/packages/wechatmp/src/index.ts @@ -7,6 +7,8 @@ import type { UserinfoEndpointHandler, } from 'next-auth/providers' import { WechatMpApi } from 'wechatmp-kit' +import { CaptchaManager } from './lib/CaptchaManager' + export type WechatPlatformConfig = { /** * 验证类型 "MESSAGE"|"QRCODE" @@ -14,7 +16,13 @@ export type WechatPlatformConfig = { * QRCODE 临时二维码 * @default "MESSAGE" */ - type: 'MESSAGE' | 'QRCODE' + checkType: 'MESSAGE' | 'QRCODE' + + /** + * 二维码图片地址 + * checkType为MESSAGE时必须配置此参数 + */ + qrcodeImageUrl?: string /** * 认证账号必须提供 * 提供二维码创建工具, @@ -22,9 +30,11 @@ export type WechatPlatformConfig = { wechatMpApi: WechatMpApi /** - * 二维码验证页面 + * 页面接口,包含: + * - 二维码展示页面 + * - 微信消息回调页面 */ - qrcodePage?: string + endpoint: string } export type WechatMpProfile = { @@ -37,11 +47,24 @@ export type WechatMpProfile = { */ unionid: string } +function checkPrint() { + // @ts-expect-error printFlag + if (global.printFlag === false) { + // @ts-expect-error printFlag + global.printFlag = true + return false + } + return true +} -type WechatMpResult

= { - options?: OAuthUserConfig

& WechatPlatformConfig +type WechatMpResult = { + GET: (req: Request) => Promise + POST: (req: Request) => Promise } +function isBlank(str?: string) { + return str === undefined || str === null || str.trim() === '' +} /** * 微信公众号平台(验证码登录) * [体验账号申请](https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login) @@ -51,32 +74,44 @@ type WechatMpResult

= { */ export default function WeChatMp

( options: OAuthUserConfig

& WechatPlatformConfig, -): OAuth2Config

& WechatMpResult

{ - const { wechatMpApi } = options ?? {} +): OAuth2Config

& WechatMpResult { + const { wechatMpApi, checkType, endpoint, qrcodeImageUrl } = Object.assign( + { + endpoint: process.env.AUTH_WECHATMP_ENDPOINT, + checkType: 'MESSAGE', + }, + options ?? {}, + ) + const captchaManager = new CaptchaManager() + + // 验证MESSAGE + if (checkType === 'MESSAGE' && isBlank(qrcodeImageUrl)) { + throw new Error('checkType为MESSAGE时,必须配置qrcodeImageUrl') + } - const message = wechatMpApi.getMessageService('', '') + const messageServicde = wechatMpApi.getMessageService( + process.env.AUTH_WECHATMP_TOKEN!, + process.env.AUTH_WECHATMP_AESKEY!, + ) + // 检验endpoint是否是完整的http + const endpointUrl = new URL(endpoint) // 跳转页面,也就是二维码 const authorization: AuthorizationEndpointHandler = { - url: 'http://localhost:3000/auth/qrcode', + url: endpointUrl.toString(), params: { - appid: clientId, + client_id: wechatMpApi.appId, response_type: 'code', - state: 'wechatmp', + action: 'qrcode', }, } // 从callback中获得state,code 然后进一步获取 - const token: TokenEndpointHandler = () => { - return { - url: 'http://localhost:3000/wechatmp/token', - async request({ code }: { code: string }) { - // 通过code获取openid,并注销code - return { - access_token: 'access_token 从缓存中获得token', - } - }, - } + const token: TokenEndpointHandler = { + url: endpoint, + params: { + action: 'token', + }, } const profile = (profile: WechatMpProfile) => { @@ -103,20 +138,189 @@ export default function WeChatMp

( } } + const userinfo: UserinfoEndpointHandler = { + url: 'http://localhost:3000/auth/qrcode2', + async request({ tokens }: { tokens: { access_token: string } }) { + return { + openid: tokens.access_token, + } + }, + } + + async function GET(request: Request): Promise { + const link = new URL(request.url) + const action = link.searchParams.get('action') + const redirectUri = link.searchParams.get('redirect_uri') + // 微信消息验证 + 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 (messageServicde.checkSign({ timestamp, nonce, signature })) { + return new Response(echo) + } + return new Response('验证失败', { status: 405 }) + } + if (action === 'qrcode') { + const code = await captchaManager.generate() + let imgLink = qrcodeImageUrl + if (checkType === 'QRCODE') { + const t = await messageServicde.createPermanentQrcode(code) + imgLink = `https://zddydd.com/qrcode/build?label=&logo=0&labelalignment=center&foreground=%23000000&background=%23ffffff&size=300&padding=10&logosize=50&labelfontsize=14&errorcorrection=medium&text=${encodeURI(t.url)}` + } + const html = ` + + + 微信公众号登录 + + +

+

请使用微信扫描二维码登录

+ +
+ + + + + ` + return new Response(html, { + headers: { + 'Content-Type': 'text/html', + }, + }) + } + + return Response.json({ data: 1 }) + } + async function POST(request: Request): Promise { + // 微信消息验证 + const link = new URL(request.url) + + const action = link.searchParams.get('action') + if (action === 'token') { + const data = await request.formData() + const valid = await captchaManager.validCode( + 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: '验证码错误', + }) + } else if (action === 'check') { + const { code } = await request.json() + const valid = await captchaManager.validCode(code) + if (valid?.openid) { + return Response.json({ type: 'success' }) + } + return Response.json({ type: 'fail' }) + } + + 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 (messageServicde.checkSign({ timestamp, nonce, signature })) { + return new Response(echo) + } + return new Response('验证失败', { status: 405 }) + } + // 获得xml消息报 + const msg_signature = request.headers.get('msg_signature') + const body = await request.text() + const message = messageServicde.parserInput(body, { + timestamp, + nonce, + signature: msg_signature ?? signature, + }) + let content = '' + if (message.MsgType == 'event') { + content = message.EventKey.replace('qrscene_', '') + } else if (message.MsgType == 'text') { + content = message.Content.trim() + } + + const status = await captchaManager.complted(content, { + openid: message.FromUserName, + }) + const result = 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', + }, + }) + } + + if (!checkPrint()) { + console.log('[auth.js/微信公众号登录插件]') + console.log('请注意以下参数') + console.log(`微信端消息回调:${endpoint}`) + console.log(`微信端消息验证类型:${options.checkType}`) + } + return { + GET, + POST, account, + clientId: wechatMpApi.appId, + clientSecret: 'TEMP', id: 'wechatmp', - name: '微信公众号关注登录', - type: 'oauth', + name: '微信公众号登录', + type: 'oauth' as const, style: { logo: '/providers/wechatOfficialAccount.svg', bg: '#fff', text: '#000', }, - checks: ['none'], + userinfo, + checks: ['none'] as ['none'], authorization, token, - userinfo, profile, } } diff --git a/packages/wechatmp/src/lib/CaptchaManager.ts b/packages/wechatmp/src/lib/CaptchaManager.ts new file mode 100644 index 0000000..657267f --- /dev/null +++ b/packages/wechatmp/src/lib/CaptchaManager.ts @@ -0,0 +1,93 @@ +export type CaptchaManagerConfig = { + /** + * 过期时间 + * @default 60000 (1min) + */ + expireTime: number + /** + * 验证码长度 + * @default 6 + */ + length: number +} +export class CaptchaManager< + T = { + openid: string + unionid?: string + }, +> { + private options: CaptchaManagerConfig + private cache: Map = new Map() + + constructor(options?: CaptchaManagerConfig) { + this.options = options || { + expireTime: 60000, + length: 6, + } + } + + /** + * 生成验证码 + */ + generate(code?: string): string { + this.cleanupExpired() + const captcha = + code ?? + Math.random() + .toString() + .substring(2, this.options.length + 2) + this.cache.set(captcha, { expireAt: Date.now() + this.options.expireTime }) + return captcha + } + + /** + * 更新验证码绑定的数据 + * @param captcha + * @param data + */ + complted(captcha: string, data: T) { + if (this.cache.has(captcha)) { + const entry = this.cache.get(captcha) + if (entry && entry.expireAt > Date.now()) { + entry.data = data + return true + } + } + return false + } + + /** + * 获取验证码绑定的数据 + * @param captcha + */ + data(captcha: string) { + const entry = this.cache.get(captcha) + if (entry && entry.expireAt > Date.now()) { + return entry.data + } + } + /** + * 校验验证码 + * @param captcha + * @returns + */ + async validCode(captcha: string): Promise { + const entry = this.cache.get(captcha) + if (entry && entry.expireAt > Date.now()) { + return entry.data + } + throw new Error('验证码不存在') + } + + /** + * 清理过期的验证码 + */ + private cleanupExpired(): void { + const now = Date.now() + for (const [captcha, entry] of this.cache.entries()) { + if (entry.expireAt <= now) { + this.cache.delete(captcha) + } + } + } +} diff --git a/packages/wechatmp/src/lib/Handle.ts b/packages/wechatmp/src/lib/Handle.ts new file mode 100644 index 0000000..e69de29