2e14873f2d
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
232 lines
7.1 KiB
JavaScript
232 lines
7.1 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import { fetch } from 'undici';
|
|
|
|
const OAUTH_AUTHORIZE_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize';
|
|
const OAUTH_ACCESS_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token';
|
|
const OAUTH_USERINFO_URL = 'https://api.weixin.qq.com/sns/userinfo';
|
|
|
|
const STATE_TTL_MS = 10 * 60 * 1000;
|
|
|
|
function resolveCallbackUrl(env = process.env) {
|
|
const explicit = env.H5_WECHAT_OAUTH_CALLBACK_URL?.trim() ?? '';
|
|
if (explicit) return explicit;
|
|
const base = env.H5_PUBLIC_BASE_URL?.trim() ?? '';
|
|
if (base) return `${base.replace(/\/$/, '')}/auth/wechat/callback`;
|
|
return '';
|
|
}
|
|
|
|
export function isWechatUserAgent(userAgent = '') {
|
|
const ua = userAgent || '';
|
|
return /MicroMessenger/i.test(ua) || /WindowsWechat/i.test(ua);
|
|
}
|
|
|
|
export function loadWechatOAuthConfig(env = process.env) {
|
|
const enabledFlag = env.H5_WECHAT_OAUTH_ENABLED === '1';
|
|
const appId = env.H5_WECHAT_APP_ID?.trim() ?? env.H5_WECHAT_OAUTH_APP_ID?.trim() ?? '';
|
|
const appSecret = env.H5_WECHAT_APP_SECRET?.trim() ?? env.H5_WECHAT_OAUTH_APP_SECRET?.trim() ?? '';
|
|
const scope = env.H5_WECHAT_OAUTH_SCOPE?.trim() || 'snsapi_userinfo';
|
|
const callbackUrl = resolveCallbackUrl(env);
|
|
const configured = Boolean(appId && appSecret && callbackUrl);
|
|
return {
|
|
enabled: enabledFlag && configured,
|
|
appId,
|
|
appSecret,
|
|
scope,
|
|
callbackUrl,
|
|
};
|
|
}
|
|
|
|
export function sanitizeWechatReturnTo(returnTo, req) {
|
|
if (!returnTo || typeof returnTo !== 'string') return '/';
|
|
const host = req.get('host');
|
|
try {
|
|
const url = new URL(returnTo, `http://${host}`);
|
|
if (url.host !== host) return '/';
|
|
if (!url.pathname.startsWith('/')) return '/';
|
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
} catch {
|
|
if (returnTo.startsWith('/') && !returnTo.startsWith('//')) return returnTo;
|
|
return '/';
|
|
}
|
|
}
|
|
|
|
function buildAuthorizeUrl(config, { state }) {
|
|
const params = new URLSearchParams({
|
|
appid: config.appId,
|
|
redirect_uri: config.callbackUrl,
|
|
response_type: 'code',
|
|
scope: config.scope,
|
|
state,
|
|
});
|
|
return `${OAUTH_AUTHORIZE_URL}?${params.toString()}#wechat_redirect`;
|
|
}
|
|
|
|
async function exchangeCode(config, code) {
|
|
const params = new URLSearchParams({
|
|
appid: config.appId,
|
|
secret: config.appSecret,
|
|
code,
|
|
grant_type: 'authorization_code',
|
|
});
|
|
const res = await fetch(`${OAUTH_ACCESS_TOKEN_URL}?${params.toString()}`);
|
|
const data = await res.json();
|
|
if (data.errcode) {
|
|
throw new Error(data.errmsg || `微信授权失败 (${data.errcode})`);
|
|
}
|
|
if (!data.openid) {
|
|
throw new Error('微信授权未返回 openid');
|
|
}
|
|
return {
|
|
accessToken: data.access_token,
|
|
openid: data.openid,
|
|
unionid: data.unionid ?? null,
|
|
refreshToken: data.refresh_token ?? null,
|
|
};
|
|
}
|
|
|
|
async function fetchUserInfo(accessToken, openid) {
|
|
const params = new URLSearchParams({
|
|
access_token: accessToken,
|
|
openid,
|
|
lang: 'zh_CN',
|
|
});
|
|
const res = await fetch(`${OAUTH_USERINFO_URL}?${params.toString()}`);
|
|
const data = await res.json();
|
|
if (data.errcode) {
|
|
throw new Error(data.errmsg || `获取微信用户信息失败 (${data.errcode})`);
|
|
}
|
|
return {
|
|
nickname: typeof data.nickname === 'string' ? data.nickname.slice(0, 128) : null,
|
|
avatarUrl: typeof data.headimgurl === 'string' ? data.headimgurl.slice(0, 512) : null,
|
|
unionid: data.unionid ?? null,
|
|
};
|
|
}
|
|
|
|
export function createWechatOAuthService(pool, config, { userAuth } = {}) {
|
|
if (!config?.enabled) {
|
|
return {
|
|
enabled: false,
|
|
publicConfig() {
|
|
return { enabled: false, inWechat: false };
|
|
},
|
|
};
|
|
}
|
|
|
|
const pruneExpiredStates = async (now = Date.now()) => {
|
|
if (!pool) return;
|
|
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE expires_at <= ?`, [now]);
|
|
};
|
|
|
|
const createState = async ({
|
|
returnTo = '/',
|
|
utmSource = null,
|
|
utmMedium = null,
|
|
utmCampaign = null,
|
|
now = Date.now(),
|
|
} = {}) => {
|
|
const state = crypto.randomBytes(24).toString('base64url');
|
|
if (pool) {
|
|
await pruneExpiredStates(now);
|
|
await pool.query(
|
|
`INSERT INTO h5_wechat_oauth_states
|
|
(state, return_to, utm_source, utm_medium, utm_campaign, expires_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
[state, returnTo, utmSource, utmMedium, utmCampaign, now + STATE_TTL_MS, now],
|
|
);
|
|
}
|
|
return state;
|
|
};
|
|
|
|
const consumeState = async (state, now = Date.now()) => {
|
|
if (!state) return null;
|
|
if (!pool) return null;
|
|
const [rows] = await pool.query(
|
|
`SELECT state, return_to, utm_source, utm_medium, utm_campaign, expires_at
|
|
FROM h5_wechat_oauth_states
|
|
WHERE state = ?
|
|
LIMIT 1`,
|
|
[state],
|
|
);
|
|
const row = rows[0];
|
|
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
|
|
if (!row || Number(row.expires_at) <= now) return null;
|
|
return {
|
|
returnTo: row.return_to || '/',
|
|
utmSource: row.utm_source,
|
|
utmMedium: row.utm_medium,
|
|
utmCampaign: row.utm_campaign,
|
|
};
|
|
};
|
|
|
|
const buildAuthorizeRedirect = async (req) => {
|
|
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
|
|
const state = await createState({
|
|
returnTo,
|
|
utmSource: typeof req.query?.utm_source === 'string' ? req.query.utm_source : null,
|
|
utmMedium: typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null,
|
|
utmCampaign: typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null,
|
|
});
|
|
return buildAuthorizeUrl(config, { state });
|
|
};
|
|
|
|
const handleCallback = async ({ code, state, ip = 'unknown' }) => {
|
|
if (!code) {
|
|
throw new Error('缺少微信授权 code');
|
|
}
|
|
const stateRow = await consumeState(state);
|
|
if (!stateRow) {
|
|
throw new Error('授权状态无效或已过期,请重新登录');
|
|
}
|
|
|
|
const tokenInfo = await exchangeCode(config, code);
|
|
let nickname = null;
|
|
let avatarUrl = null;
|
|
let unionid = tokenInfo.unionid;
|
|
|
|
if (config.scope === 'snsapi_userinfo') {
|
|
const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid);
|
|
nickname = profile.nickname;
|
|
avatarUrl = profile.avatarUrl;
|
|
unionid = profile.unionid ?? unionid;
|
|
}
|
|
|
|
if (!userAuth?.loginByWechat) {
|
|
throw new Error('用户系统未就绪');
|
|
}
|
|
|
|
const result = await userAuth.loginByWechat({
|
|
appId: config.appId,
|
|
openid: tokenInfo.openid,
|
|
unionid,
|
|
nickname,
|
|
avatarUrl,
|
|
ip,
|
|
utmSource: stateRow.utmSource,
|
|
utmMedium: stateRow.utmMedium,
|
|
utmCampaign: stateRow.utmCampaign,
|
|
});
|
|
if (!result.ok) {
|
|
throw new Error(result.message || '微信登录失败');
|
|
}
|
|
return {
|
|
token: result.token,
|
|
user: result.user,
|
|
isNewUser: result.isNewUser,
|
|
returnTo: stateRow.returnTo,
|
|
utmSource: stateRow.utmSource,
|
|
utmMedium: stateRow.utmMedium,
|
|
utmCampaign: stateRow.utmCampaign,
|
|
};
|
|
};
|
|
|
|
return {
|
|
enabled: true,
|
|
publicConfig(req) {
|
|
const inWechat = req ? isWechatUserAgent(req.get?.('user-agent') || '') : false;
|
|
return { enabled: true, inWechat };
|
|
},
|
|
buildAuthorizeRedirect,
|
|
handleCallback,
|
|
};
|
|
}
|