662 lines
16 KiB
JavaScript
662 lines
16 KiB
JavaScript
import {
|
|
isWechatUserAgent,
|
|
loadWechatOAuthConfig,
|
|
} from '../wechat-oauth.mjs';
|
|
import {
|
|
validateWechatShareSignatureUrl,
|
|
} from '../wechat-share.mjs';
|
|
|
|
export function attachPortalWechatAuthRoutes({
|
|
app,
|
|
jsonBody,
|
|
userAuthReady = Promise.resolve(),
|
|
getUserAuth = () => null,
|
|
getWechatOAuthService = () => null,
|
|
getWechatMpService = () => null,
|
|
wechatMpConfig,
|
|
userToken,
|
|
setUserLoginCookies,
|
|
isSecureRequest,
|
|
getPlazaSeo = () => null,
|
|
plazaClientIp = (req) => req.ip,
|
|
logger = console,
|
|
isWechatUserAgentFn = isWechatUserAgent,
|
|
loadWechatOAuthConfigFn = loadWechatOAuthConfig,
|
|
validateWechatShareSignatureUrlFn =
|
|
validateWechatShareSignatureUrl,
|
|
} = {}) {
|
|
if (
|
|
!app ||
|
|
typeof jsonBody !== 'function' ||
|
|
!wechatMpConfig ||
|
|
typeof userToken !== 'function' ||
|
|
typeof setUserLoginCookies !== 'function' ||
|
|
typeof isSecureRequest !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'attachPortalWechatAuthRoutes requires route dependencies',
|
|
);
|
|
}
|
|
|
|
app.get('/auth/wechat/config', async (req, res) => {
|
|
await userAuthReady;
|
|
const wechatOAuthService =
|
|
getWechatOAuthService();
|
|
if (!wechatOAuthService?.enabled) {
|
|
return res.json({
|
|
enabled: false,
|
|
inWechat: isWechatUserAgentFn(
|
|
req.get('user-agent') || '',
|
|
),
|
|
scanEnabled: false,
|
|
});
|
|
}
|
|
return res.json(
|
|
wechatOAuthService.publicConfig(req),
|
|
);
|
|
});
|
|
|
|
app.get(
|
|
'/auth/wechat/js-sdk-signature',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const userAuth = getUserAuth();
|
|
const wechatMpService =
|
|
getWechatMpService();
|
|
if (!userAuth || !wechatMpService?.enabled) {
|
|
return res.status(503).json({
|
|
message: '微信 JS-SDK 未启用',
|
|
});
|
|
}
|
|
const me = await userAuth.getMe(userToken(req));
|
|
if (!me) {
|
|
return res.status(401).json({
|
|
message: '未登录',
|
|
});
|
|
}
|
|
const pageUrl = String(
|
|
req.query?.url ?? '',
|
|
).split('#')[0];
|
|
if (!pageUrl) {
|
|
return res.status(400).json({
|
|
message: '缺少 url',
|
|
});
|
|
}
|
|
try {
|
|
const normalizedUrl =
|
|
validateWechatShareSignatureUrlFn(pageUrl, {
|
|
publicBaseUrl:
|
|
wechatMpConfig.publicBaseUrl,
|
|
requestHost:
|
|
req.get('x-forwarded-host') ||
|
|
req.get('host') ||
|
|
'',
|
|
});
|
|
const payload =
|
|
await wechatMpService.createJsSdkSignature(
|
|
normalizedUrl,
|
|
);
|
|
return res.json(payload);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: '微信 JS-SDK 签名失败';
|
|
logger.warn(
|
|
'WeChat JS-SDK signature failed:',
|
|
message,
|
|
);
|
|
return res.status(502).json({ message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
'/auth/wechat/public-js-sdk-signature',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const wechatMpService =
|
|
getWechatMpService();
|
|
if (!wechatMpService?.enabled) {
|
|
return res.status(503).json({
|
|
message: '微信 JS-SDK 未启用',
|
|
});
|
|
}
|
|
const pageUrl = String(
|
|
req.query?.url ?? '',
|
|
).split('#')[0];
|
|
if (!pageUrl) {
|
|
return res.status(400).json({
|
|
message: '缺少 url',
|
|
});
|
|
}
|
|
try {
|
|
const normalizedUrl =
|
|
validateWechatShareSignatureUrlFn(pageUrl, {
|
|
publicBaseUrl:
|
|
wechatMpConfig.publicBaseUrl,
|
|
requestHost:
|
|
req.get('x-forwarded-host') ||
|
|
req.get('host') ||
|
|
'',
|
|
});
|
|
const payload =
|
|
await wechatMpService.createJsSdkSignature(
|
|
normalizedUrl,
|
|
);
|
|
return res.json(payload);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: '微信 JS-SDK 签名失败';
|
|
logger.warn(
|
|
'WeChat public JS-SDK signature failed:',
|
|
message,
|
|
);
|
|
return res.status(502).json({ message });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get('/auth/wechat/status', async (req, res) => {
|
|
await userAuthReady;
|
|
const userAuth = getUserAuth();
|
|
const wechatOAuthService =
|
|
getWechatOAuthService();
|
|
if (!userAuth || !wechatOAuthService?.enabled) {
|
|
return res.json({
|
|
enabled: false,
|
|
bound: false,
|
|
});
|
|
}
|
|
const me = await userAuth.getMe(userToken(req));
|
|
if (!me) {
|
|
return res.status(401).json({
|
|
message: '未登录',
|
|
});
|
|
}
|
|
const config = loadWechatOAuthConfigFn();
|
|
const status =
|
|
await userAuth.getWechatBindingStatus(
|
|
me.id,
|
|
config.appId,
|
|
);
|
|
return res.json({
|
|
enabled: true,
|
|
...status,
|
|
});
|
|
});
|
|
|
|
if (wechatMpConfig.enabled) {
|
|
app.get(
|
|
'/auth/wechat/agent-route',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const userAuth = getUserAuth();
|
|
const wechatMpService =
|
|
getWechatMpService();
|
|
if (
|
|
!userAuth ||
|
|
!wechatMpService?.enabled
|
|
) {
|
|
return res.status(503).json({
|
|
message: '公众号 Agent 未启用',
|
|
});
|
|
}
|
|
const me = await userAuth.getMe(
|
|
userToken(req),
|
|
);
|
|
if (!me) {
|
|
return res.status(401).json({
|
|
message: '未登录',
|
|
});
|
|
}
|
|
return res.json(
|
|
await wechatMpService.getRouteStatusForUser(
|
|
me.id,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
'/auth/wechat/agent-route/reset',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const userAuth = getUserAuth();
|
|
const wechatMpService =
|
|
getWechatMpService();
|
|
if (
|
|
!userAuth ||
|
|
!wechatMpService?.enabled
|
|
) {
|
|
return res.status(503).json({
|
|
message: '公众号 Agent 未启用',
|
|
});
|
|
}
|
|
const me = await userAuth.getMe(
|
|
userToken(req),
|
|
);
|
|
if (!me) {
|
|
return res.status(401).json({
|
|
message: '未登录',
|
|
});
|
|
}
|
|
try {
|
|
const result =
|
|
await wechatMpService.recreateRouteForUser(
|
|
me.id,
|
|
);
|
|
if (!result.ok) {
|
|
return res.status(400).json({
|
|
message: result.message,
|
|
});
|
|
}
|
|
return res.json(result);
|
|
} catch (error) {
|
|
return res.status(500).json({
|
|
message:
|
|
error instanceof Error
|
|
? error.message
|
|
: '重建公众号 Agent 路由失败',
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
app.get(
|
|
'/auth/wechat/pending/:token',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const userAuth = getUserAuth();
|
|
if (!userAuth) {
|
|
return res.status(503).json({
|
|
message: '未启用用户系统',
|
|
});
|
|
}
|
|
const pending =
|
|
await userAuth.getWechatPendingBind(
|
|
req.params.token,
|
|
);
|
|
if (!pending) {
|
|
return res.status(404).json({
|
|
message: '绑定会话已过期,请重新微信登录',
|
|
});
|
|
}
|
|
return res.json({
|
|
nickname: pending.nickname,
|
|
avatarUrl: pending.avatar_url,
|
|
returnTo: pending.return_to || '/',
|
|
});
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
'/auth/wechat/register',
|
|
jsonBody,
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const secure = isSecureRequest(req);
|
|
const userAuth = getUserAuth();
|
|
if (!userAuth) {
|
|
return res.status(503).json({
|
|
message: '未启用用户系统',
|
|
});
|
|
}
|
|
const pendingToken =
|
|
typeof req.body?.pendingToken === 'string'
|
|
? req.body.pendingToken
|
|
: '';
|
|
if (!pendingToken) {
|
|
return res.status(400).json({
|
|
message: '缺少绑定会话',
|
|
});
|
|
}
|
|
const result =
|
|
await userAuth.completeWechatRegister({
|
|
pendingToken,
|
|
});
|
|
if (!result.ok) {
|
|
return res.status(400).json({
|
|
message: result.message,
|
|
});
|
|
}
|
|
const plazaSeo = getPlazaSeo();
|
|
if (result.isNewUser && plazaSeo) {
|
|
void plazaSeo
|
|
.recordAttribution(
|
|
{
|
|
event_type: 'signup',
|
|
utm_source:
|
|
result.utmSource || 'wechat',
|
|
utm_medium: result.utmMedium,
|
|
utm_campaign: result.utmCampaign,
|
|
user_id: result.user?.id ?? null,
|
|
},
|
|
plazaClientIp(req),
|
|
)
|
|
.catch(() => {});
|
|
}
|
|
void secure;
|
|
setUserLoginCookies(
|
|
res,
|
|
req,
|
|
result.token,
|
|
);
|
|
return res.json({
|
|
authenticated: true,
|
|
user: result.user,
|
|
returnTo: result.returnTo || '/',
|
|
});
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
'/auth/wechat/bind',
|
|
jsonBody,
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const secure = isSecureRequest(req);
|
|
const userAuth = getUserAuth();
|
|
if (!userAuth) {
|
|
return res.status(503).json({
|
|
message: '未启用用户系统',
|
|
});
|
|
}
|
|
const pendingToken =
|
|
typeof req.body?.pendingToken === 'string'
|
|
? req.body.pendingToken
|
|
: '';
|
|
const username =
|
|
typeof req.body?.username === 'string'
|
|
? req.body.username
|
|
: '';
|
|
const password =
|
|
typeof req.body?.password === 'string'
|
|
? req.body.password
|
|
: '';
|
|
if (!pendingToken) {
|
|
return res.status(400).json({
|
|
message: '缺少绑定会话',
|
|
});
|
|
}
|
|
if (!username || !password) {
|
|
return res.status(400).json({
|
|
message: '用户名和密码不能为空',
|
|
});
|
|
}
|
|
const result =
|
|
await userAuth.completeWechatBindAccount({
|
|
pendingToken,
|
|
username,
|
|
password,
|
|
ip: req.ip,
|
|
});
|
|
if (!result.ok) {
|
|
const status =
|
|
result.retryAfterMs > 0 ? 429 : 401;
|
|
if (result.retryAfterMs > 0) {
|
|
res.set(
|
|
'Retry-After',
|
|
String(
|
|
Math.ceil(result.retryAfterMs / 1000),
|
|
),
|
|
);
|
|
}
|
|
return res.status(status).json({
|
|
message: result.message,
|
|
});
|
|
}
|
|
void secure;
|
|
setUserLoginCookies(
|
|
res,
|
|
req,
|
|
result.token,
|
|
);
|
|
return res.json({
|
|
authenticated: true,
|
|
user: result.user,
|
|
bound: true,
|
|
returnTo: result.returnTo || '/',
|
|
});
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
'/auth/wechat/scan/start',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const wechatOAuthService =
|
|
getWechatOAuthService();
|
|
if (!wechatOAuthService?.enabled) {
|
|
return res.status(503).json({
|
|
message: '微信登录未启用',
|
|
});
|
|
}
|
|
try {
|
|
const payload =
|
|
await wechatOAuthService.startScanLogin(
|
|
req,
|
|
);
|
|
return res.json(payload);
|
|
} catch (error) {
|
|
return res.status(503).json({
|
|
message:
|
|
error instanceof Error
|
|
? error.message
|
|
: '微信扫码登录不可用',
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
'/auth/wechat/scan/poll',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const wechatOAuthService =
|
|
getWechatOAuthService();
|
|
if (!wechatOAuthService?.enabled) {
|
|
return res.status(503).json({
|
|
message: '微信登录未启用',
|
|
});
|
|
}
|
|
const state =
|
|
typeof req.query?.state === 'string'
|
|
? req.query.state
|
|
: '';
|
|
if (!state) {
|
|
return res.status(400).json({
|
|
message: '缺少扫码状态',
|
|
});
|
|
}
|
|
const result =
|
|
await wechatOAuthService.pollScanLogin(
|
|
state,
|
|
);
|
|
if (
|
|
result.status === 'complete' &&
|
|
result.token
|
|
) {
|
|
setUserLoginCookies(
|
|
res,
|
|
req,
|
|
result.token,
|
|
);
|
|
}
|
|
return res.json(result);
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
'/auth/wechat/authorize',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const wechatOAuthService =
|
|
getWechatOAuthService();
|
|
if (!wechatOAuthService?.enabled) {
|
|
return res.status(503).json({
|
|
message: '微信登录未启用',
|
|
});
|
|
}
|
|
try {
|
|
const userAuth = getUserAuth();
|
|
let bindUserId = null;
|
|
const intent =
|
|
typeof req.query?.intent === 'string'
|
|
? req.query.intent
|
|
.trim()
|
|
.toLowerCase()
|
|
: 'login';
|
|
if (intent === 'bind' && userAuth) {
|
|
const me = await userAuth.getMe(
|
|
userToken(req),
|
|
);
|
|
if (!me) {
|
|
return res.status(401).json({
|
|
message: '请先登录后再绑定微信',
|
|
});
|
|
}
|
|
bindUserId = me.id;
|
|
}
|
|
const redirectUrl =
|
|
await wechatOAuthService.buildAuthorizeRedirect(
|
|
req,
|
|
{ bindUserId },
|
|
);
|
|
return res.redirect(302, redirectUrl);
|
|
} catch (error) {
|
|
logger.error(
|
|
'WeChat authorize failed:',
|
|
error,
|
|
);
|
|
return res.status(500).json({
|
|
message:
|
|
error instanceof Error
|
|
? error.message
|
|
: '微信授权失败',
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
'/auth/wechat/callback',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const secure = isSecureRequest(req);
|
|
const wechatOAuthService =
|
|
getWechatOAuthService();
|
|
const userAuth = getUserAuth();
|
|
if (
|
|
!wechatOAuthService?.enabled ||
|
|
!userAuth
|
|
) {
|
|
return res.redirect(
|
|
302,
|
|
'/?wechat_error=unavailable',
|
|
);
|
|
}
|
|
try {
|
|
const result =
|
|
await wechatOAuthService.handleCallback({
|
|
code:
|
|
typeof req.query?.code === 'string'
|
|
? req.query.code
|
|
: '',
|
|
state:
|
|
typeof req.query?.state === 'string'
|
|
? req.query.state
|
|
: '',
|
|
ip: req.ip,
|
|
});
|
|
|
|
if (result.action === 'binding_gate') {
|
|
const params = new URLSearchParams();
|
|
params.set(
|
|
'wechat_pending',
|
|
result.pendingToken,
|
|
);
|
|
if (
|
|
result.returnTo &&
|
|
result.returnTo !== '/'
|
|
) {
|
|
params.set(
|
|
'return_to',
|
|
result.returnTo,
|
|
);
|
|
}
|
|
return res.redirect(
|
|
302,
|
|
`/?${params.toString()}`,
|
|
);
|
|
}
|
|
|
|
if (result.action === 'poll_error') {
|
|
return res.redirect(
|
|
302,
|
|
`/?wechat_error=${encodeURIComponent(
|
|
result.message || '微信登录失败',
|
|
)}`,
|
|
);
|
|
}
|
|
|
|
const plazaSeo = getPlazaSeo();
|
|
if (result.isNewUser && plazaSeo) {
|
|
void plazaSeo
|
|
.recordAttribution(
|
|
{
|
|
event_type: 'signup',
|
|
utm_source:
|
|
result.utmSource || 'wechat',
|
|
utm_medium: result.utmMedium,
|
|
utm_campaign:
|
|
result.utmCampaign,
|
|
user_id:
|
|
result.user?.id ?? null,
|
|
},
|
|
plazaClientIp(req),
|
|
)
|
|
.catch(() => {});
|
|
}
|
|
|
|
if (
|
|
result.authMode === 'open' ||
|
|
result.authMode === 'scan'
|
|
) {
|
|
return res.send(
|
|
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>微信登录</title></head><body><p>扫码登录成功,请返回电脑继续操作。</p></body></html>',
|
|
);
|
|
}
|
|
|
|
void secure;
|
|
setUserLoginCookies(
|
|
res,
|
|
req,
|
|
result.token,
|
|
);
|
|
return res.redirect(
|
|
302,
|
|
result.returnTo || '/',
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
'WeChat callback failed:',
|
|
error,
|
|
);
|
|
const message = encodeURIComponent(
|
|
error instanceof Error
|
|
? error.message
|
|
: '微信登录失败',
|
|
);
|
|
return res.redirect(
|
|
302,
|
|
`/?wechat_error=${message}`,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|